mlx-bun 0.0.13 → 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 (224) hide show
  1. package/README.md +31 -21
  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/{planning → archive/planning}/release-notes-v0.0.9.md +1 -1
  8. package/docs/{planning → archive/planning}/website-readme-pass-plan.md +1 -1
  9. package/docs/design/colibri-glm52-port.md +1 -1
  10. package/docs/design/compat-cli-surface-design.md +1 -1
  11. package/docs/design/decode-speed-program.md +1 -1
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +1 -1
  14. package/docs/design/dspark-speculative-decoding.md +53 -3
  15. package/docs/design/grammar-spec-batching-integration.md +1 -1
  16. package/docs/design/mlx-lm-serving-execution-seam.md +81 -0
  17. package/docs/design/orpo-dynamic-lambda.md +1 -1
  18. package/docs/design/orpo-future-enhancements.md +1 -1
  19. package/docs/design/orpo-training.md +2 -2
  20. package/docs/design/parity-tier-dag.md +1 -1
  21. package/docs/design/repo-cleanup-plan.md +46 -4
  22. package/docs/design/segmented-backward-training.md +2 -2
  23. package/docs/design/ssd-kv-cold-tier.md +127 -0
  24. package/docs/design/turboquant-weights.md +467 -0
  25. package/docs/design/w4a16-compute-precision-spike.md +1 -1
  26. package/docs/design/web-chat-redesign.md +5 -5
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/curve-sampler-research-plan.md +4 -4
  29. package/docs/planning/memory-docs-and-dag-plan.md +1 -1
  30. package/docs/planning/release-notes-v0.2.0.md +49 -0
  31. package/docs/planning/video-series.md +421 -0
  32. package/docs/reference/cli.md +15 -4
  33. package/docs/reference/distribution.md +1 -0
  34. package/docs/reference/features-matrix.md +3 -2
  35. package/docs/reference/library-api.md +41 -1
  36. package/docs/reference/models.md +33 -0
  37. package/docs/reference/server-api.md +69 -14
  38. package/docs/reference/server-config.md +57 -33
  39. package/package.json +2 -2
  40. package/src/anthropic.ts +92 -3
  41. package/src/cli.ts +131 -36
  42. package/src/eval/kl.ts +27 -20
  43. package/src/eval/runner.ts +23 -4
  44. package/src/eval/tasks/bfcl.ts +2 -1
  45. package/src/eval/tasks/gsm8k.ts +2 -1
  46. package/src/eval/tasks/hashhop.ts +2 -1
  47. package/src/eval/tasks/humaneval.ts +5 -4
  48. package/src/eval/tasks/ifeval.ts +2 -1
  49. package/src/eval/tasks/mmlu.ts +2 -1
  50. package/src/expert-io.ts +3 -1
  51. package/src/expert-offload.ts +3 -1
  52. package/src/expert-trace.ts +3 -1
  53. package/src/fit.ts +24 -43
  54. package/src/flags.ts +7 -9
  55. package/src/generate.ts +107 -92
  56. package/src/grammar.ts +2 -1
  57. package/src/harness-pi.ts +1 -1
  58. package/src/index.ts +20 -0
  59. package/src/jobs/job-entry.ts +3 -2
  60. package/src/kv-scheme.ts +199 -0
  61. package/src/kv-store.ts +370 -205
  62. package/src/media-fetch.ts +19 -3
  63. package/src/memory/model.ts +2 -1
  64. package/src/memory/vault.ts +2 -1
  65. package/src/memory-plan.ts +18 -0
  66. package/src/mlx/ffi.ts +8 -11
  67. package/src/mlx/ops.ts +31 -6
  68. package/src/model/batched-mask.ts +1 -1
  69. package/src/model/batched-quant.ts +1 -1
  70. package/src/model/batched-rotating-quant.ts +43 -97
  71. package/src/model/batched-rotating-state.ts +73 -0
  72. package/src/model/batched-rotating.ts +56 -105
  73. package/src/model/batched-row-storage.ts +127 -0
  74. package/src/model/compiled-decode.ts +3 -2
  75. package/src/model/factory.ts +44 -49
  76. package/src/model/gemma4-base.ts +163 -10
  77. package/src/model/generated/gemma4-12b.ts +50 -146
  78. package/src/model/generated/gemma4-26b.ts +32 -92
  79. package/src/model/generated/gemma4-e4b.ts +26 -74
  80. package/src/model/glm52-cache.ts +19 -9
  81. package/src/model/glm52-memory.ts +28 -1
  82. package/src/model/minicpm5.ts +2 -1
  83. package/src/model/profile.ts +433 -0
  84. package/src/model/qwen3-delta.ts +98 -2
  85. package/src/model/qwen3-mrope.ts +252 -0
  86. package/src/model/qwen3_5.ts +217 -25
  87. package/src/native/frame_extract.swift +140 -0
  88. package/src/native-pack.ts +13 -6
  89. package/src/pi-web.ts +6 -5
  90. package/src/prompt-cache.ts +2 -2
  91. package/src/quantize/atomic-output.ts +38 -0
  92. package/src/quantize/calibration.ts +2 -1
  93. package/src/quantize/config-writer.ts +6 -0
  94. package/src/quantize/index.ts +28 -1
  95. package/src/quantize/job.ts +11 -0
  96. package/src/quantize/quantizer.ts +265 -100
  97. package/src/quantize/rotate.ts +603 -0
  98. package/src/quantize/safetensors-writer.ts +79 -1
  99. package/src/quantize/sensitivity.ts +41 -59
  100. package/src/quantize/weight-transform.ts +292 -0
  101. package/src/responses.ts +102 -2
  102. package/src/runtime-config.ts +67 -0
  103. package/src/sampler.ts +272 -0
  104. package/src/serve/admin-routes.ts +184 -0
  105. package/src/serve/aux-routes.ts +132 -0
  106. package/src/serve/batch-scheduler.ts +200 -87
  107. package/src/serve/completion-executor.ts +346 -0
  108. package/src/serve/completion-sink.ts +157 -0
  109. package/src/serve/discovery-routes.ts +205 -0
  110. package/src/serve/generation-gateway.ts +116 -109
  111. package/src/serve/kv-budget.ts +14 -0
  112. package/src/serve/lab-routes.ts +338 -0
  113. package/src/serve/model-admin-routes.ts +153 -0
  114. package/src/serve/prompt-response-trace.ts +147 -0
  115. package/src/serve/request-plan.ts +158 -0
  116. package/src/serve/static-routes.ts +101 -0
  117. package/src/server.ts +704 -1400
  118. package/src/spec/dflash-source.ts +2 -1
  119. package/src/spec/dspark/module-dflash.ts +1 -1
  120. package/src/spec/glm52-mtp-source.ts +8 -0
  121. package/src/spec/qwen-mtp-source.ts +431 -0
  122. package/src/spec/serve-loop.ts +36 -68
  123. package/src/ssd-cache.ts +17 -14
  124. package/src/ssd-durability.ts +195 -0
  125. package/src/train/flash-cce.ts +12 -11
  126. package/src/train/forward.ts +32 -1
  127. package/src/train/loss.ts +2 -1
  128. package/src/train/segmented.ts +4 -3
  129. package/src/train/trainer.ts +3 -2
  130. package/src/vision/preprocess.ts +1 -1
  131. package/src/vision/prompt.ts +47 -1
  132. package/src/vision/qwen3vl-preprocess.ts +409 -0
  133. package/src/vision/qwen3vl-prompt.ts +196 -0
  134. package/src/vision/qwen3vl-tower.ts +513 -0
  135. package/src/vision/video-frames.ts +148 -0
  136. package/src/web/app.js +10 -3
  137. package/src/web/src/chat.ts +12 -5
  138. package/src/weights.ts +81 -26
  139. /package/docs/{investigations → archive/investigations}/chunk-finetune-experiment.md +0 -0
  140. /package/docs/{investigations → archive/investigations}/colibri-oracle-pin.md +0 -0
  141. /package/docs/{investigations → archive/investigations}/curve-bisector-routing.md +0 -0
  142. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-route.json +0 -0
  143. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-trace.json +0 -0
  144. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.json +0 -0
  145. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.svg +0 -0
  146. /package/docs/{investigations → archive/investigations}/curve-terrain.html +0 -0
  147. /package/docs/{investigations → archive/investigations}/decode-roofline-lookagain.md +0 -0
  148. /package/docs/{investigations → archive/investigations}/dspark-handoff.md +0 -0
  149. /package/docs/{investigations → archive/investigations}/expert-offload-single-user-moe.md +0 -0
  150. /package/docs/{investigations → archive/investigations}/hlg-ab.html +0 -0
  151. /package/docs/{investigations → archive/investigations}/hlg-explorer.html +0 -0
  152. /package/docs/{investigations → archive/investigations}/hlg-figs/cliff.svg +0 -0
  153. /package/docs/{investigations → archive/investigations}/hlg-figs/cross.svg +0 -0
  154. /package/docs/{investigations → archive/investigations}/hlg-figs/edges.svg +0 -0
  155. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-grids.svg +0 -0
  156. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-peaks.svg +0 -0
  157. /package/docs/{investigations → archive/investigations}/hlg-figs/lw-curve.svg +0 -0
  158. /package/docs/{investigations → archive/investigations}/hlg-figs/terrain.svg +0 -0
  159. /package/docs/{investigations → archive/investigations}/hlg-report.html +0 -0
  160. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-ab.json +0 -0
  161. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind-prompts.md +0 -0
  162. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind.json +0 -0
  163. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cliff.json +0 -0
  164. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cross.json +0 -0
  165. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-edge.json +0 -0
  166. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-eval1.json +0 -0
  167. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-frontier.json +0 -0
  168. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid.json +0 -0
  169. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid2.json +0 -0
  170. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-lw-coarse.json +0 -0
  171. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-map.json +0 -0
  172. /package/docs/{investigations → archive/investigations}/hlg-runs/run0-original.log +0 -0
  173. /package/docs/{investigations → archive/investigations}/hlg-runs/runA-recommended.log +0 -0
  174. /package/docs/{investigations → archive/investigations}/hlg-runs/runAA-cliff-cpm5.log +0 -0
  175. /package/docs/{investigations → archive/investigations}/hlg-runs/runAB-grid2-e4b.log +0 -0
  176. /package/docs/{investigations → archive/investigations}/hlg-runs/runAC-peak-e4b-partial.log +0 -0
  177. /package/docs/{investigations → archive/investigations}/hlg-runs/runAD-ab-samples.log +0 -0
  178. /package/docs/{investigations → archive/investigations}/hlg-runs/runAE-edge-e4b.log +0 -0
  179. /package/docs/{investigations → archive/investigations}/hlg-runs/runAF-logit-calibration.log +0 -0
  180. /package/docs/{investigations → archive/investigations}/hlg-runs/runAG-eval-config2.log +0 -0
  181. /package/docs/{investigations → archive/investigations}/hlg-runs/runB-gain-variants.log +0 -0
  182. /package/docs/{investigations → archive/investigations}/hlg-runs/runC-pivots.log +0 -0
  183. /package/docs/{investigations → archive/investigations}/hlg-runs/runD-oetf.log +0 -0
  184. /package/docs/{investigations → archive/investigations}/hlg-runs/runE-eotf.log +0 -0
  185. /package/docs/{investigations → archive/investigations}/hlg-runs/runF-shaper-working.log +0 -0
  186. /package/docs/{investigations → archive/investigations}/hlg-runs/runG-shaper-sweep.log +0 -0
  187. /package/docs/{investigations → archive/investigations}/hlg-runs/runH-wsweep.log +0 -0
  188. /package/docs/{investigations → archive/investigations}/hlg-runs/runI-asweep.log +0 -0
  189. /package/docs/{investigations → archive/investigations}/hlg-runs/runJ-knobmap.log +0 -0
  190. /package/docs/{investigations → archive/investigations}/hlg-runs/runK-frontier.log +0 -0
  191. /package/docs/{investigations → archive/investigations}/hlg-runs/runL-widemap.log +0 -0
  192. /package/docs/{investigations → archive/investigations}/hlg-runs/runM-frontier-emb.log +0 -0
  193. /package/docs/{investigations → archive/investigations}/hlg-runs/runN-verify.log +0 -0
  194. /package/docs/{investigations → archive/investigations}/hlg-runs/runO-frontier-seed5000.log +0 -0
  195. /package/docs/{investigations → archive/investigations}/hlg-runs/runP-grid-AxSm.log +0 -0
  196. /package/docs/{investigations → archive/investigations}/hlg-runs/runQ-grid-tgW.log +0 -0
  197. /package/docs/{investigations → archive/investigations}/hlg-runs/runR-blind-transcript.log +0 -0
  198. /package/docs/{investigations → archive/investigations}/hlg-runs/runS-cross-coherence.log +0 -0
  199. /package/docs/{investigations → archive/investigations}/hlg-runs/runT-cross-cpm5.log +0 -0
  200. /package/docs/{investigations → archive/investigations}/hlg-runs/runU-cross-12b.log +0 -0
  201. /package/docs/{investigations → archive/investigations}/hlg-runs/runV-lw-e4b.log +0 -0
  202. /package/docs/{investigations → archive/investigations}/hlg-runs/runW-lw-12b.log +0 -0
  203. /package/docs/{investigations → archive/investigations}/hlg-runs/runX-lw-cpm5.log +0 -0
  204. /package/docs/{investigations → archive/investigations}/hlg-runs/runY-cliff-e4b.log +0 -0
  205. /package/docs/{investigations → archive/investigations}/hlg-runs/runZ-cliff-12b.log +0 -0
  206. /package/docs/{investigations → archive/investigations}/indexshare-performance-spike.md +0 -0
  207. /package/docs/{investigations → archive/investigations}/kernel-perf-review-2026-07.md +0 -0
  208. /package/docs/{investigations → archive/investigations}/lab-build-journal.md +0 -0
  209. /package/docs/{investigations → archive/investigations}/openwiki-evaluation.md +0 -0
  210. /package/docs/{investigations → archive/investigations}/orpo-base-uf-experiment-and-directions.md +0 -0
  211. /package/docs/{investigations → archive/investigations}/orpo-flash-cce-pin-leak.md +0 -0
  212. /package/docs/{investigations → archive/investigations}/orpo-uf-testing-handoff.md +0 -0
  213. /package/docs/{investigations → archive/investigations}/pi-builtin-investigation.md +0 -0
  214. /package/docs/{investigations → archive/investigations}/segmented-backward-handoff.md +0 -0
  215. /package/docs/{investigations → archive/investigations}/starter-model-port-handoff.md +0 -0
  216. /package/docs/{investigations → archive/investigations}/steel-flash-cce-handoff.md +0 -0
  217. /package/docs/{investigations → archive/investigations}/trainer-validation-experiment.md +0 -0
  218. /package/docs/{planning → archive/planning}/chat-ui-vision-memory-first.md +0 -0
  219. /package/docs/{planning → archive/planning}/journal.md +0 -0
  220. /package/docs/{planning → archive/planning}/release-notes-v0.0.10.md +0 -0
  221. /package/docs/{planning → archive/planning}/release-notes-v0.0.11.md +0 -0
  222. /package/docs/{planning → archive/planning}/release-notes-v0.0.12.md +0 -0
  223. /package/docs/{planning → archive/planning}/release-notes-v0.0.13.md +0 -0
  224. /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;
@@ -311,10 +421,10 @@ 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";
314
- // Detect Qwen's companion head so loadContext can refuse it precisely.
315
- // The target's 48 recurrent DeltaNet caches cannot yet roll back rejected
316
- // verification rows, so treating this as a generic two-model draft would
317
- // fail later and obscure the actual unsupported composition.
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*).
318
428
  if (String(cfg.model_type ?? "").endsWith("_mtp")) return "mtp";
319
429
  } catch {
320
430
  // no/unreadable config → fall through to a full second model
@@ -322,11 +432,6 @@ export async function detectDraftKind(dir: string): Promise<DraftKind> {
322
432
  return "two-model";
323
433
  }
324
434
 
325
- export const QWEN_MTP_UNSUPPORTED_REASON =
326
- "Qwen native MTP is not supported yet: its target has recurrent DeltaNet " +
327
- "caches that cannot roll back rejected verification tokens. Serve Qwen3.8 " +
328
- "without --draft-model; MTP remains a gated follow-up.";
329
-
330
435
  async function loadGenSamplingDefaults(modelDir: string): Promise<GenSamplingDefaults> {
331
436
  const file = Bun.file(`${modelDir}/generation_config.json`);
332
437
  if (!(await file.exists())) return {};
@@ -349,17 +454,27 @@ export async function loadContext(
349
454
  opts: LoadContextOptions = {},
350
455
  ): Promise<ServerContext> {
351
456
  const config = await loadModelConfig(modelDir);
352
- 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
+ }
353
474
  const externalDraft = opts.draftModelDir !== undefined || opts.draftKind !== undefined;
354
- // Refuse the known-impossible composition before opening the 20 GB target
355
- // or the companion head. The shared speculative loop requires target-cache
356
- // rollback after a partial reject; Qwen's DeltaNet state is recurrent and
357
- // deliberately non-trimmable. Silently degrading would make --draft-model
358
- // a lie, while forcing it would corrupt the next target step.
359
475
  const resolvedDraftKind = opts.draftModelDir
360
476
  ? opts.draftKind ?? await detectDraftKind(opts.draftModelDir)
361
477
  : opts.draftKind;
362
- if (resolvedDraftKind === "mtp") throw new Error(QWEN_MTP_UNSUPPORTED_REASON);
363
478
  if (glm && externalDraft && opts.glm?.enableMtp === true)
364
479
  throw new Error("native GLM MTP and --draft-model/--draft-kind are mutually exclusive");
365
480
  let weights: Weights | null = null;
@@ -396,9 +511,7 @@ export async function loadContext(
396
511
  `${(opts.memoryBudgetBytes / 1e9).toFixed(2)} GB`,
397
512
  );
398
513
  }
399
- // generated-specialization dispatch by config fingerprint (Phase C);
400
- // unmatched configs run the monolith — slow, never broken
401
- if (!glm) model = createModel(weights!, config);
514
+ if (!glm) model = createModel(weights!, config, profile);
402
515
  const tokenizer = await loadTokenizer(modelDir);
403
516
  // Generation must stop on the tokenizer's eos_token — the chat turn
404
517
  // terminator (e.g. Qwen <|im_end|> = 248046). Some configs (Qwen3.5-4B)
@@ -451,6 +564,17 @@ export async function loadContext(
451
564
  } else if (kind === "assistant") {
452
565
  const { AssistantProvider } = await import("./spec/assistant-source");
453
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);
454
578
  } else {
455
579
  const { TwoModelProvider } = await import("./spec/two-model");
456
580
  provider = await TwoModelProvider.load(dir, config.text.vocabSize);
@@ -531,6 +655,7 @@ export async function loadContext(
531
655
  return {
532
656
  draft,
533
657
  model,
658
+ profile,
534
659
  glmMemoryPlan,
535
660
  adapters: new AdapterManager(model),
536
661
  kvConfig: config.kvQuant,
@@ -571,6 +696,21 @@ export async function loadContext(
571
696
  function makeVisionLoader(
572
697
  modelDir: string, model: RuntimeModel, config: ModelConfig,
573
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
+ }
574
714
  if (!(config.hasVisionSidecar && model instanceof Gemma4Model)) return null;
575
715
  const vc = config.raw.vision_config as Record<string, any> | undefined;
576
716
  if (vc?.model_type === "gemma4_vision") {
@@ -736,30 +876,29 @@ type TextCompletionRequest = Omit<ChatRequest, "messages" | "tools" | "tool_choi
736
876
  export interface ContextAdmissionDecision {
737
877
  /** Effective completion ceiling after admission. */
738
878
  maxTokens: number;
739
- /** True when a fixed-context runtime reduced the client's upper bound. */
879
+ /** True when admission reduced the client's upper bound to fit. */
740
880
  clamped: boolean;
741
881
  }
742
882
 
743
883
  /** Resolve a request's completion upper bound against the admitted context.
744
884
  *
745
- * Generic memory-budget serving preserves the established fail-fast contract:
746
- * an oversized reservation is rejected. A fixed-context runtime such as GLM
747
- * may instead clamp the client's upper bound to the remaining planned context.
748
- * `max_tokens` is a ceiling, not a promise that every token will be emitted;
749
- * clamping keeps clients that send a broad family-wide default usable without
750
- * weakening the prompt/context OOM guard. A prompt that leaves no generation
751
- * slot is always rejected. */
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. */
752
893
  export function admitRequestContext(
753
894
  promptTokens: number,
754
895
  requestedMaxTokens: number,
755
896
  maxSafeContext: number,
756
- clampToFit: boolean,
757
897
  ): ContextAdmissionDecision | null {
758
898
  const available = maxSafeContext - promptTokens;
759
899
  if (available < 1) return null;
760
900
  if (requestedMaxTokens <= available)
761
901
  return { maxTokens: requestedMaxTokens, clamped: false };
762
- if (!clampToFit) return null;
763
902
  return { maxTokens: available, clamped: true };
764
903
  }
765
904
 
@@ -1181,7 +1320,8 @@ function hasMediaPart(parts: Array<Record<string, unknown>>): boolean {
1181
1320
  (p) =>
1182
1321
  p &&
1183
1322
  (p.type === "image" || p.type === "image_url" ||
1184
- 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"),
1185
1325
  );
1186
1326
  }
1187
1327
 
@@ -1253,7 +1393,8 @@ export function parseLogitBias(
1253
1393
 
1254
1394
  /** Default seed for a request that didn't pin one. `Date.now()` alone is NOT
1255
1395
  * request-unique: under `--batch N` the batch lane serves ONLY default-seed
1256
- * requests (explicit-seed requests route serial, see GenerationGateway.willBatch),
1396
+ * requests (explicit-seed requests use the serial mechanism, see
1397
+ * GenerationGateway.place),
1257
1398
  * so two identical prompts arriving in the same millisecond would share a seed
1258
1399
  * and — with per-row RNG keyed as stepKey(seed, generatedCount) — produce
1259
1400
  * byte-identical completions, silently collapsing best-of-N diversity. Mix a
@@ -1345,68 +1486,24 @@ export function validateLogprobsParams(body: {
1345
1486
  return null;
1346
1487
  }
1347
1488
 
1348
- /** Collects per-token logprob info and shapes mlx_lm.server's response block
1349
- * (server.py generate_response L1317-1327). NOT OpenAI's shape — entries
1350
- * carry token *ids* (and raw vocab token strings in the top-k form), and the
1351
- * SAME block is attached under choices[0].logprobs for chat AND text
1352
- * completions:
1353
- * - top_logprobs > 0 → {content: [{id, token, logprob,
1354
- * top_logprobs: [{id, token, logprob}, …]}, …]} — each entry is the
1355
- * top-1 candidate merged with its own top-k list (`dict(i[0],
1356
- * top_logprobs=i)`); mlx-lm leaves argpartition order unspecified, we
1357
- * sort descending, so the entry is deterministically the argmax.
1358
- * - logprobs=true (and top_logprobs ≤ 0) → {content: [{id, logprob}, …]}
1359
- * with the SAMPLED token's logprob.
1360
- * When both are set, only the top_logprobs form is emitted (the reference's
1361
- * if/elif). Stream chunks never carry logprobs — mlx-lm's streaming
1362
- * generate_response calls pass no token_logprobs/top_tokens. */
1363
- class LogprobsCollector {
1364
- readonly #tokens: number[] = [];
1365
- readonly #tokenLogprobs: number[] = [];
1366
- readonly #topTokens: { id: number; token: string; logprob: number }[][] = [];
1367
-
1368
- constructor(
1369
- private readonly wantLogprobs: boolean,
1370
- private readonly topK: number,
1371
- private readonly idToToken: (id: number) => string,
1372
- ) {}
1373
-
1374
- get active(): boolean {
1375
- return this.wantLogprobs || this.topK > 0;
1376
- }
1377
-
1378
- push(token: number, info?: TokenLogprobs): void {
1379
- if (!this.active) return;
1380
- this.#tokens.push(token);
1381
- if (this.wantLogprobs) this.#tokenLogprobs.push(info?.logprob ?? NaN);
1382
- if (this.topK > 0)
1383
- this.#topTokens.push(
1384
- (info?.top ?? []).map((t) => ({
1385
- id: t.id,
1386
- token: this.idToToken(t.id),
1387
- logprob: t.logprob,
1388
- })),
1389
- );
1390
- }
1391
-
1392
- /** choices[0].logprobs value, or null when nothing to attach (mirrors the
1393
- * reference: the key is omitted entirely for zero collected tokens). */
1394
- payload(): { content: Record<string, unknown>[] } | null {
1395
- if (this.#topTokens.length)
1396
- return {
1397
- content: this.#topTokens.map((t) =>
1398
- t.length ? { ...t[0]!, top_logprobs: t } : {},
1399
- ),
1400
- };
1401
- if (this.#tokenLogprobs.length)
1402
- return {
1403
- content: this.#tokens.map((id, i) => ({
1404
- id,
1405
- logprob: this.#tokenLogprobs[i]!,
1406
- })),
1407
- };
1408
- return null;
1409
- }
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;
1410
1507
  }
1411
1508
 
1412
1509
  /** Incremental detokenizer: emits the longest stable decoded prefix.
@@ -1560,13 +1657,14 @@ function curveJunk(s: string): boolean {
1560
1657
  export function createServer(
1561
1658
  ctx: ServerContext, port = 0, serverOptions: ServerOptions = {},
1562
1659
  ): Server<unknown> {
1563
- // --batch N (mode switch): N===1 is the serialized path below; N>1 routes
1564
- // batchable requests through the continuous-batching scheduler (the
1565
- // GenerationGateway picks the lane). Both full-attention (CPM) and
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
1566
1664
  // sliding-window (Gemma) models batch — the scheduler assembles each layer's
1567
1665
  // cache by attention type. Non-batchable requests (vision / adapters /
1568
- // repetition penalty / user seed / explicit kv-quant) drain to the serial
1569
- // lane (see GenerationGateway.willBatch).
1666
+ // user seed / unsupported explicit kv-quant) drain to the serial executor
1667
+ // (see GenerationGateway.place). No inference setting is rewritten.
1570
1668
  // DEFAULT 8 (flipped 2026-07-05, Josh's call, after GATE-B1-SPEED): a
1571
1669
  // lone request through the batch lane IS the serial engine (adopted
1572
1670
  // serial-class caches, compiled decode, prompt cache + SSD restore;
@@ -1585,7 +1683,6 @@ export function createServer(
1585
1683
  // and must be an explicit opt-in (--kv-quant config|4|8, or --l2
1586
1684
  // whose presets pass it explicitly). The CLI always passes kvQuant now;
1587
1685
  // this fallback is the library-user default and matches the CLI's.
1588
- const configScheme = ctx.kvConfig?.length ? { kvConfig: ctx.kvConfig } : {};
1589
1686
  // Mutually exclusive by contract (GenerateOptions.turboQuant doc): a
1590
1687
  // programmatic caller setting both gets turboQuant — say so, like the
1591
1688
  // other risky-combination warnings below.
@@ -1594,13 +1691,12 @@ export function createServer(
1594
1691
  `[kv-quant] both turboQuant and --kv-quant ${serverOptions.kvQuant} are set; ` +
1595
1692
  `turboQuant wins (they are mutually exclusive).`,
1596
1693
  );
1597
- const kvScheme: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart" | "turboQuant"> =
1598
- serverOptions.turboQuant ? { turboQuant: serverOptions.turboQuant, quantizedKvStart: 0 }
1599
- : serverOptions.kvQuant === "off" ? {}
1600
- : serverOptions.kvQuant === "config" ? configScheme
1601
- : typeof serverOptions.kvQuant === "number"
1602
- ? { kvBits: serverOptions.kvQuant, quantizedKvStart: 0 }
1603
- : {}; // unset → bf16 (L1 default; quantized KV is opt-in)
1694
+ const resolvedKvScheme = resolveKvScheme({
1695
+ override: serverOptions.kvQuant,
1696
+ turboQuant: serverOptions.turboQuant,
1697
+ config: ctx.kvConfig,
1698
+ });
1699
+ const kvScheme = resolvedKvScheme.generationOptions;
1604
1700
  // Phase 3.1: kvConfig whose layers are all full-attention BATCHES (the
1605
1701
  // scheduler applies the mixed scheme per row); uniform kvBits and configs
1606
1702
  // touching rotating layers still route those requests serial. The warning
@@ -1613,7 +1709,7 @@ export function createServer(
1613
1709
  `--kv-quant to batch in bf16. (docs/design/unified-engine-frontier-plan.md)`,
1614
1710
  );
1615
1711
  // TurboQuant is solo-only in v1 (novel cache class, not batchable by
1616
- // construction — see GenerationGateway#modelCachesBatchable/willBatch).
1712
+ // construction — see GenerationGateway placement's cache-capability gate).
1617
1713
  if (batch > 1 && kvScheme.turboQuant)
1618
1714
  console.warn(
1619
1715
  `[batch] --batch ${batch} with --kv-quant turbo: TurboQuant is serial-only in v1 ` +
@@ -1699,11 +1795,7 @@ export function createServer(
1699
1795
  if (serverOptions.ssdCacheDir) {
1700
1796
  if (promptCacheCap <= 0)
1701
1797
  throw new Error("--ssd-cache requires the RAM prompt cache (--prompt-cache 0 disables it)");
1702
- const schemeKey = kvScheme.turboQuant
1703
- ? `turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`
1704
- : kvScheme.kvBits
1705
- ? `kv${kvScheme.kvBits}`
1706
- : kvScheme.kvConfig?.length ? "config" : "bf16";
1798
+ const schemeKey = resolvedKvScheme.cacheKey;
1707
1799
  const tokJson = readFileSync(`${ctx.model.config.modelDir}/tokenizer.json`);
1708
1800
  ssdStore = new SsdCacheStore({
1709
1801
  dir: serverOptions.ssdCacheDir,
@@ -1789,8 +1881,17 @@ export function createServer(
1789
1881
  embeddings: import("./mlx/array").MlxArray;
1790
1882
  imageMask?: import("./mlx/array").MlxArray;
1791
1883
  multimodalMask?: import("./mlx/array").MlxArray;
1884
+ mrope?: MropeRequestState;
1792
1885
  },
1886
+ trace?: PromptResponseTrace,
1793
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).
1794
1895
  // Speculative decoding (serve --draft-model): spec-ELIGIBLE requests
1795
1896
  // decode through the verify loop; the rest fall through to the normal
1796
1897
  // serial path (never wrong results, just no speedup — logged once per
@@ -1841,7 +1942,12 @@ export function createServer(
1841
1942
  const skipPromptCache = Boolean(vision || pagedKv);
1842
1943
  // Both tiers in one call (Layer 0): take() prefers a strictly-longer
1843
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
+ });
1844
1949
  const entry = skipPromptCache ? null : promptCache.take(promptIds, cacheNs);
1950
+ closeCacheLookup?.();
1845
1951
  const caches = entry?.caches ?? ctx.model.makeCache();
1846
1952
  // Prompt-boundary snapshot (the multi-turn agent fix, 2026-07-04): the
1847
1953
  // prompt+gen entry put() below is UNTRIMMABLE at context > sliding
@@ -1865,6 +1971,8 @@ export function createServer(
1865
1971
  const snapshotBoundary =
1866
1972
  !skipPromptCache && boundary >= 256 && boundary > (entry?.tokens.length ?? 0);
1867
1973
  try {
1974
+ if (vision?.mrope && ctx.model instanceof Qwen35Model)
1975
+ ctx.model.mrope = vision.mrope;
1868
1976
  const gen = generate(ctx.model, promptIds, {
1869
1977
  ...options,
1870
1978
  pagedKv, // request-scoped (undefined strips the server-wide flag)
@@ -1893,7 +2001,7 @@ export function createServer(
1893
2001
  ...(vision.multimodalMask ? { multimodalMask: vision.multimodalMask } : {}),
1894
2002
  }
1895
2003
  : {}),
1896
- });
2004
+ }, { trace, mechanism: "serial" });
1897
2005
  for await (const t of gen) {
1898
2006
  if ((await onToken(t.token, t.logprobs)) === false) break;
1899
2007
  }
@@ -1918,6 +2026,7 @@ export function createServer(
1918
2026
  options.grammar?.dispose();
1919
2027
  throw e;
1920
2028
  } finally {
2029
+ if (ctx.model instanceof Qwen35Model) ctx.model.mrope = null;
1921
2030
  vision?.embeddings.dispose();
1922
2031
  vision?.imageMask?.dispose();
1923
2032
  vision?.multimodalMask?.dispose();
@@ -1934,12 +2043,10 @@ export function createServer(
1934
2043
  // extraction must not race a generation mutating the entry); if the entry
1935
2044
  // was evicted (already spilled) or extended (a newer schedule pending)
1936
2045
  // meanwhile, findExact misses and nothing is written.
1937
- // Keyed by ns AND entry length: a sub-second final [prompt+gen] put must
1938
- // not cancel the pending boundary-snapshot write (they are DIFFERENT
1939
- // entries; for wrapped-ring models the boundary file is the only one a
1940
- // restart can use). Same-length reschedules still coalesce; stale keys
1941
- // self-clean at fire time (findExact misses once the RAM tier superseded
1942
- // the entry, so nothing extra is written).
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.
1943
2050
  //
1944
2051
  // NON-BLOCKING (2026-07-06, the write-behind persistence contract): the
1945
2052
  // gateway lock is held only for a zero-copy SNAPSHOT (findExact +
@@ -1964,23 +2071,28 @@ export function createServer(
1964
2071
  // still land ahead of a just-arrived request. MLX_BUN_SSD_WRITEBEHIND=0
1965
2072
  // disables write-behind snapshots entirely — the paired-A/B lever + kill
1966
2073
  // switch (restart survival then degrades to spill-on-evict only).
1967
- const writeBehindOn = process.env.MLX_BUN_SSD_WRITEBEHIND !== "0";
2074
+ const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
1968
2075
  const ssdFlushGate = (): Promise<void> => gateway.onIdle();
1969
- const ssdPending = new Map<string, ReturnType<typeof setTimeout>>();
1970
2076
  // Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
1971
2077
  // kv-store.ts): pending clones pin their entries' GPU buffers while the
1972
2078
  // idle gate starves under sustained traffic, so QUEUED bytes are capped —
1973
2079
  // over cap the oldest queued spill drops (clone disposed immediately, a
1974
2080
  // future cache miss, never a wrong result) instead of accumulating past
1975
2081
  // the prompt-cache cap. Default 2 GB (a quarter of the 8 GB RAM-cache
1976
- // default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides. There is deliberately
1977
- // NO shutdown flush: exiting under traffic loses queued write-behind
1978
- // entries (restart survival degrades to whatever flushed) — accepted for
1979
- // a best-effort cache tier; drops/pending are visible in /stats.
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.
1980
2085
  // `|| 2` would coerce an explicit "0" back to 2 GB — parse so 0 works
1981
2086
  // (cap 0 = keep only the newest + in-flight clone pinned; the soft cap
1982
2087
  // never drops the item just enqueued).
1983
- 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"));
1984
2096
  const spillQueueCapBytes =
1985
2097
  (Number.isFinite(spillQueueGbRaw) && spillQueueGbRaw >= 0 ? spillQueueGbRaw : 2) * 1024 ** 3;
1986
2098
  const spillQueue = ssdStore
@@ -1991,38 +2103,39 @@ export function createServer(
1991
2103
  (caches) => { for (const c of caches) c.dispose(); },
1992
2104
  )
1993
2105
  : null;
1994
- const scheduleSsdSnapshot = (tokens: number[], ns: string): void => {
1995
- if (!ssdStore || !writeBehindOn || tokens.length === 0) return;
1996
- const key = `${ns}|${tokens.length}`;
1997
- const prev = ssdPending.get(key);
1998
- if (prev) clearTimeout(prev);
1999
- const fire = (): void => {
2000
- ssdPending.delete(key);
2001
- // Same guard as demoteIdle below (2026-07-07 review fix): the
2002
- // runExclusive registers a serial waiter, which HOLDS BATCH ADMISSION
2003
- // until every running row drains — a background durability timer must
2004
- // never do that. Busy → re-arm and try again in 5 s (entries are
2005
- // immutable; a late snapshot is just as valid).
2006
- if (gateway.activeRows > 0 || gateway.pendingRows > 0) {
2007
- const t = setTimeout(fire, 5000);
2008
- t.unref?.();
2009
- ssdPending.set(key, t);
2010
- return;
2011
- }
2012
- void gateway.runExclusive(async () => {
2013
- const e = promptCache.findExact(tokens, ns);
2014
- if (!e) return null;
2015
- return { tokens: e.tokens, caches: cloneKvCaches(e.caches) };
2016
- }).then((snap) => {
2017
- if (!snap) return;
2018
- spillQueue!.enqueue({ tokens: snap.tokens, caches: snap.caches, ns });
2019
- }).catch(() => { /* cold tier is best-effort */ });
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,
2020
2134
  };
2021
- ssdPending.set(key, setTimeout(fire, 1000));
2022
2135
  };
2023
2136
  // Every put() — serial lane AND batch scheduler — schedules the snapshot
2024
2137
  // (Layer 0: batch-lane entries survive restarts too, not just evictions).
2025
- promptCache.onPut = (tokens, ns) => scheduleSsdSnapshot(tokens, ns);
2138
+ promptCache.onPut = durability ? (tokens, ns) => durability.schedule(tokens, ns) : null;
2026
2139
 
2027
2140
  // Idle demotion (Layer 0): entries unused for --ssd-demote-idle seconds
2028
2141
  // spill to SSD and free their GPU memory — the RAM tier drains between
@@ -2046,52 +2159,49 @@ export function createServer(
2046
2159
  demoteTimer.unref?.();
2047
2160
  }
2048
2161
 
2049
- // The lane picker: routes each request to the serial path (runGeneration,
2050
- // above) or the continuous-batching scheduler, keeping the two off the GPU
2051
- // (and shared loraState) at the same time. See src/serve/generation-gateway.ts.
2052
- const gateway = new GenerationGateway(ctx.model, batch, runGeneration, {
2053
- kvBudgetBytes: serverOptions.kvBudgetBytes,
2054
- // Phase 3.1: the server-wide scheme travels to the gateway once; the
2055
- // gateway batches kv-quant requests when the scheme is the batchable
2056
- // kvConfig/full-attention composition (see #kvBatchable) and the
2057
- // scheduler applies it — otherwise those requests route serial as before.
2058
- kvScheme,
2059
- // Phase 3.2: batch-lane prompt-cache reuse — joiners take() the longest
2060
- // usable prefix (multi-turn chat TTFT under --batch N); never-merged
2061
- // lone rows put() back on finish. Vision/adapter requests never batch,
2062
- // so the serial lane's bypass rules are preserved by routing.
2063
- promptCache,
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,
2064
2173
  });
2065
2174
 
2066
2175
  // Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
2067
2176
  // fit() solves max safe context from weights + KV growth + prefill
2068
- // transient; the KV term assumes bf16 (a kv-quant scheme stretches the
2069
- // real ceiling, never shrinks it — admission stays conservative).
2070
- const genericAdmission = fit(
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(
2071
2183
  ctx.model.config, ctx.model.weightsBytes, 1,
2072
2184
  undefined, undefined, 0, serverOptions.memoryBudgetBytes,
2185
+ resolvedKvScheme.fitOptions,
2073
2186
  );
2074
- const admission = ctx.glmMemoryPlan
2075
- ? {
2076
- ...genericAdmission,
2077
- maxSafeContext: ctx.glmMemoryPlan.contextTokens,
2078
- usableBytes: ctx.glmMemoryPlan.processLimitBytes,
2079
- }
2080
- : 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.
2081
2192
  if (admission.maxSafeContext < 1)
2082
- throw new Error(
2083
- `memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB cannot serve ` +
2084
- `${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB): ` +
2085
- `no context fits — raise the budget or pick a smaller model`,
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`,
2086
2197
  );
2087
- if (ctx.glmMemoryPlan)
2088
- setMemoryLimit(ctx.glmMemoryPlan.lineItems.allocatorReserveBytes);
2089
- else if (serverOptions.memoryBudgetBytes)
2090
- setMemoryLimit(serverOptions.memoryBudgetBytes);
2198
+ const allocatorLimit =
2199
+ admission.allocatorLimitBytes ?? serverOptions.memoryBudgetBytes;
2200
+ if (allocatorLimit) setMemoryLimit(allocatorLimit);
2091
2201
 
2092
2202
  // /library response cache (30 s) — registry + config reads only.
2093
- let libraryCache: { at: number; rows: unknown[] } | null = null;
2094
2203
  const startedAt = Date.now();
2204
+ const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt);
2095
2205
 
2096
2206
  // Captured so the WebSocket handler can resolve the bound (possibly
2097
2207
  // ephemeral) port lazily for the loopback pi provider.
@@ -2355,250 +2465,36 @@ export function createServer(
2355
2465
  async fetch(request, server) {
2356
2466
  const url = new URL(request.url);
2357
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
+
2358
2481
  if (url.pathname === "/ws/chat") {
2359
2482
  if (server.upgrade(request, { data: { sessionId: crypto.randomUUID() } as PiWsData }))
2360
2483
  return undefined;
2361
2484
  return new Response("expected websocket", { status: 426 });
2362
2485
  }
2363
2486
 
2364
- // Memory REST wrappers (web-chat-redesign.md §2.3/§9 Phase 2): thin
2365
- // loopback JSON routes over src/memory/vault.ts for the web chat's
2366
- // Memory panel. Handlers live in src/memory/rest.ts (pure functions,
2367
- // no `ctx` dependency) so they're unit-testable without a loaded
2368
- // model; dispatch here just matches path+method. The agent-tool
2369
- // surface (src/memory/tools.ts) is untouched by this block.
2370
- if (url.pathname === "/api/memory/status" && request.method === "GET") {
2371
- const { handleMemoryStatus } = await import("./memory/rest");
2372
- return handleMemoryStatus();
2373
- }
2374
- if (url.pathname === "/api/memory/list" && request.method === "GET") {
2375
- const { handleMemoryList } = await import("./memory/rest");
2376
- return handleMemoryList();
2377
- }
2378
- if (url.pathname === "/api/memory/search" && request.method === "GET") {
2379
- const { handleMemorySearch } = await import("./memory/rest");
2380
- return handleMemorySearch(url);
2381
- }
2382
- if (url.pathname === "/api/memory/article" && request.method === "GET") {
2383
- const { handleMemoryArticle } = await import("./memory/rest");
2384
- return handleMemoryArticle(url);
2385
- }
2386
- if (url.pathname === "/api/memory/links" && request.method === "GET") {
2387
- const { handleMemoryLinks } = await import("./memory/rest");
2388
- return handleMemoryLinks(url);
2389
- }
2390
- if (url.pathname === "/api/memory/history" && request.method === "GET") {
2391
- const { handleMemoryHistory } = await import("./memory/rest");
2392
- return handleMemoryHistory(url);
2393
- }
2394
- if (url.pathname === "/api/memory/diff" && request.method === "GET") {
2395
- const { handleMemoryDiff } = await import("./memory/rest");
2396
- return handleMemoryDiff(url);
2397
- }
2398
- if (url.pathname === "/api/memory/init" && request.method === "POST") {
2399
- const { handleMemoryInit } = await import("./memory/rest");
2400
- return handleMemoryInit(request);
2401
- }
2402
-
2403
- // Model Hub (web-chat-redesign.md §9 Phase 3, beat-matrix Axis 3):
2404
- // thin loopback JSON routes over the registry/download/fit machinery
2405
- // for the web chat's Hub panel. Handlers live in src/hub-rest.ts
2406
- // (pure functions, no `ctx` dependency); dispatch here just matches
2407
- // path+method, same convention as the memory wrappers above.
2408
- if (url.pathname === "/api/hub/local" && request.method === "GET") {
2409
- const { handleHubLocal } = await import("./hub-rest");
2410
- return handleHubLocal();
2411
- }
2412
- if (url.pathname === "/api/hub/search" && request.method === "GET") {
2413
- const { handleHubSearch } = await import("./hub-rest");
2414
- return handleHubSearch(url);
2415
- }
2416
- if (url.pathname === "/api/hub/download" && request.method === "POST") {
2417
- const { handleHubDownload } = await import("./hub-rest");
2418
- return handleHubDownload(request);
2419
- }
2420
- if (url.pathname === "/api/hub/serve" && request.method === "POST") {
2421
- const { handleHubServe } = await import("./hub-rest");
2422
- return handleHubServe(request);
2423
- }
2424
-
2425
- // Session full-text search + export (docs/design/web-chat-redesign.md
2426
- // §9 Phase 3, beat-matrix Axis 10/11 — the full-text-search BEAT and
2427
- // Markdown/JSON chat export). Handlers live in src/serve/session-search.ts
2428
- // (pure, read-only, no `ctx` dependency), dispatched here by
2429
- // path+method, same convention as the memory/hub blocks above.
2430
- if (url.pathname === "/api/sessions/search" && request.method === "GET") {
2431
- const { handleSessionsSearch } = await import("./serve/session-search");
2432
- return handleSessionsSearch(url);
2433
- }
2434
- if (url.pathname === "/api/sessions/export" && request.method === "GET") {
2435
- const { handleSessionsExport } = await import("./serve/session-search");
2436
- return handleSessionsExport(url);
2437
- }
2438
-
2439
- // The unified SPA is served at "/"; legacy deep links redirect into
2440
- // the hash router so old bookmarks still land on the right section.
2441
- if (url.pathname === "/" && request.method === "GET") {
2442
- return new Response(APP_PAGE, {
2443
- headers: { "content-type": "text/html; charset=utf-8" },
2444
- });
2445
- }
2446
- // Vendored syntax-highlighting assets (src/web/vendor/*, no CDN — see
2447
- // that dir's README). Long cache: content is versioned by the vendor
2448
- // rebuild step, not by URL, but a hard reload always wins during dev.
2449
- if (url.pathname === "/assets/hljs.js" && request.method === "GET") {
2450
- return new Response(HLJS_JS, {
2451
- headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
2452
- });
2453
- }
2454
- if (url.pathname === "/assets/hljs.css" && request.method === "GET") {
2455
- return new Response(HLJS_CSS, {
2456
- headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=3600" },
2457
- });
2458
- }
2459
- // The frontend bundle (see the import comment above) — cache header
2460
- // matches the other /assets/* entries; content is versioned by the
2461
- // build step, not the URL, and a hard reload always wins during dev.
2462
- if (url.pathname === "/assets/app.js" && request.method === "GET") {
2463
- return new Response(APP_JS, {
2464
- headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
2465
- });
2466
- }
2467
- // PWA manifest + icon (plan §9 Phase 3): linked from app.html's
2468
- // <link rel="manifest">. Same versioned-by-content cache posture as
2469
- // the other /assets/* entries above.
2470
- if (url.pathname === "/manifest.webmanifest" && request.method === "GET") {
2471
- return new Response(MANIFEST_WEBMANIFEST, {
2472
- headers: { "content-type": "application/manifest+json; charset=utf-8", "cache-control": "public, max-age=3600" },
2473
- });
2474
- }
2475
- if (url.pathname === "/assets/icon.svg" && request.method === "GET") {
2476
- return new Response(ICON_SVG, {
2477
- headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "public, max-age=3600" },
2478
- });
2479
- }
2480
- // Service worker MUST be served at the root scope (/sw.js, not
2481
- // /assets/sw.js) — a worker's default scope is the directory it's
2482
- // served from, and the shell it manages includes "/" itself.
2483
- // no-store: the browser's own update check needs a fresh byte
2484
- // comparison every time to notice a new worker, not a cached 304.
2485
- if (url.pathname === "/sw.js" && request.method === "GET") {
2486
- return new Response(SW_JS, {
2487
- headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" },
2488
- });
2489
- }
2490
- // v2 HLG Curve Designer — served same-origin so /generate + /signal need no CORS.
2491
- // Read fresh from disk in dev (edits show on reload, no restart); fall back to the
2492
- // embedded copy when running as the compiled single binary.
2493
- if (url.pathname === "/curves" && request.method === "GET") {
2494
- let html = CURVE_PAGE;
2495
- try { html = readFileSync(new URL("./assets/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
2496
- return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
2497
- }
2498
- // Training/inference DAG map — self-contained cytoscape artifact, served
2499
- // same-origin so the Routes tab can embed it in an <iframe src="/dag">.
2500
- if (url.pathname === "/dag" && request.method === "GET") {
2501
- try {
2502
- const html = readFileSync(new URL("../docs/dag/training-inference-map.html", import.meta.url), "utf8");
2503
- return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
2504
- } catch {
2505
- return new Response("DAG map artifact not found at docs/dag/training-inference-map.html", { status: 404 });
2506
- }
2507
- }
2508
- if (url.pathname === "/curve-terrain" && request.method === "GET") {
2509
- try {
2510
- const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
2511
- return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
2512
- } catch {
2513
- return new Response("curve terrain artifact not found; run scripts/experiments/curve-terrain.ts first", { status: 404 });
2514
- }
2515
- }
2516
- if (request.method === "GET" &&
2517
- ["/status", "/chat", "/quantize", "/finetune", "/dataset"].includes(url.pathname)) {
2518
- return Response.redirect(`/#${url.pathname}`, 302);
2519
- }
2487
+ const auxiliaryResponse = await handleAuxiliaryRoute(url, request);
2488
+ if (auxiliaryResponse) return auxiliaryResponse;
2520
2489
 
2521
- if (url.pathname === "/library" && request.method === "GET") {
2522
- // Everything on disk, each with a fit assessment for THIS machine
2523
- // (30 s cache — registry scan + config reads, no tensor bytes).
2524
- if (!libraryCache || Date.now() - libraryCache.at > 30_000) {
2525
- const { Registry } = await import("./registry");
2526
- const { loadModelConfig } = await import("./config");
2527
- const reg = new Registry();
2528
- // Always rescan when (re)building the 30s cache so models that appeared
2529
- // after boot — fresh downloads AND quants written into the HF cache —
2530
- // surface on their own. (scan() is INSERT-OR-REPLACE + prunes deleted,
2531
- // so it's idempotent and cheap for a local cache.)
2532
- await reg.scan();
2533
- const { visionCapable, audioCapable } = await import("./registry");
2534
- const { supportTier } = await import("./model/support");
2535
- const rows = [];
2536
- // listCanonical: one row per repo (refs/main) — duplicate snapshots
2537
- // from upstream re-pushes stay visible only in `ls --all-revisions`.
2538
- for (const m of reg.listCanonical()) {
2539
- const tier = supportTier(m.modelType, m.repoId);
2540
- const supported = tier !== null;
2541
- let assessment = null;
2542
- try {
2543
- const config = await loadModelConfig(m.path);
2544
- const r = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
2545
- assessment = {
2546
- fits: r.fits,
2547
- max_safe_context: r.maxSafeContext,
2548
- predicted_decode_tps: r.predictedDecodeTps,
2549
- };
2550
- } catch {}
2551
- rows.push({
2552
- repo_id: m.repoId, model_type: m.modelType,
2553
- size_bytes: m.sizeBytes, quant_bits: m.quantBits,
2554
- vision: visionCapable(m), audio: audioCapable(m),
2555
- supported, support_tier: tier,
2556
- serving: m.repoId === ctx.modelId,
2557
- assessment,
2558
- });
2559
- }
2560
- libraryCache = { at: Date.now(), rows };
2561
- }
2562
- return Response.json({ models: libraryCache.rows });
2563
- }
2490
+ const staticResponse = handleStaticRoute(url, request, STATIC_ROUTE_ASSETS);
2491
+ if (staticResponse) return staticResponse;
2564
2492
 
2565
- if (url.pathname === "/downloads" && request.method === "GET") {
2566
- const { downloadsSnapshot } = await import("./download");
2567
- return Response.json({ downloads: downloadsSnapshot() });
2568
- }
2493
+ const discoveryResponse = await discoveryRoutes.handle(url, request);
2494
+ if (discoveryResponse) return discoveryResponse;
2569
2495
 
2570
- // Memory synthesis progress (P8-T5). The same DAG the nightly launchd job
2571
- // runs (`mlx-bun memory synthesize`), streamed as Server-Sent Events so the
2572
- // status page can show live stage/log/done progress. `?dry=1` plans the DAG
2573
- // without any model call or vault write — the safe wiring-verification path
2574
- // (the FULL-corpus run is USER-ACTION, P6-T5). GET so EventSource can drive it.
2575
- if (url.pathname === "/v1/memory/synthesize" && request.method === "GET") {
2576
- const dryRun = url.searchParams.get("dry") === "1";
2577
- const { runSynthesis } = await import("./memory/pipeline");
2578
- const enc = new TextEncoder();
2579
- const stream = new ReadableStream({
2580
- async start(controller) {
2581
- const send = (e: unknown) =>
2582
- controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
2583
- try {
2584
- const summary = await runSynthesis({ dryRun }, (ev) => send(ev));
2585
- send({ type: "summary", ...summary });
2586
- controller.enqueue(enc.encode("data: [DONE]\n\n"));
2587
- } catch (e) {
2588
- send({ type: "error", message: (e as Error).message });
2589
- } finally {
2590
- controller.close();
2591
- }
2592
- },
2593
- });
2594
- return new Response(stream, {
2595
- headers: {
2596
- "content-type": "text/event-stream",
2597
- "cache-control": "no-cache",
2598
- connection: "keep-alive",
2599
- },
2600
- });
2601
- }
2496
+ const modelAdminResponse = await handleModelAdminRoute(url, request, ctx, gateway);
2497
+ if (modelAdminResponse) return modelAdminResponse;
2602
2498
 
2603
2499
  if (url.pathname === "/fit" && request.method === "GET") {
2604
2500
  // Fit assessment for the status page: this-machine report at the
@@ -2674,6 +2570,7 @@ export function createServer(
2674
2570
  const report = fit(
2675
2571
  ctx.model.config, ctx.model.weightsBytes, admission.maxSafeContext,
2676
2572
  machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
2573
+ resolvedKvScheme.fitOptions,
2677
2574
  );
2678
2575
  return Response.json({
2679
2576
  machine: { chip: chip.name, ram_bytes: machine.ramBytes, bandwidth_gbs: machine.bandwidthGBs },
@@ -2686,6 +2583,7 @@ export function createServer(
2686
2583
  ctx.model.config, ctx.model.weightsBytes,
2687
2584
  Math.min(8192, admission.maxSafeContext),
2688
2585
  machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
2586
+ resolvedKvScheme.fitOptions,
2689
2587
  ).predictedDecodeTps,
2690
2588
  measured_decode_tps: measured?.decodeTps ?? null,
2691
2589
  measured_at: measured?.ts ?? null,
@@ -2707,18 +2605,6 @@ export function createServer(
2707
2605
  });
2708
2606
  }
2709
2607
 
2710
- if (url.pathname === "/v1" && request.method === "GET") {
2711
- return Response.json({
2712
- name: "mlx-bun", version: pkgVersion, model: ctx.modelId,
2713
- endpoints: [
2714
- "POST /v1/chat/completions", "POST /v1/completions", "POST /v1/messages",
2715
- "POST /v1/responses", "POST /v1/embeddings", "GET /v1/models",
2716
- "GET/POST/DELETE /v1/adapters", "GET /health",
2717
- "GET /stats", "GET /fit", "GET /library", "GET /downloads",
2718
- ],
2719
- });
2720
- }
2721
-
2722
2608
  if (url.pathname === "/stats" && request.method === "GET") {
2723
2609
  // Active KV scheme across ALL layers. Since Phase 9 rotating
2724
2610
  // (sliding-window) caches quantize too, so every layer the
@@ -2767,12 +2653,12 @@ export function createServer(
2767
2653
  spills: ssdStore.stats.spills,
2768
2654
  restore_ms_last: Math.round(ssdStore.stats.restoreMsLast),
2769
2655
  demotions: promptCache.demotions,
2770
- // Bounded write-behind queue (2026-07-07): clones waiting for
2771
- // the idle-gated flush, and spills the byte cap dropped
2772
- // (each = one future cache miss, never a wrong result).
2656
+ pending_snapshots: durability?.stats.pendingSnapshots ?? 0,
2773
2657
  pending_spills: spillQueue?.pendingCount ?? 0,
2774
2658
  pending_spill_bytes: spillQueue?.pendingBytes ?? 0,
2775
2659
  dropped_spills: spillQueue?.droppedCount ?? 0,
2660
+ failed_spills: spillQueue?.failedCount ?? 0,
2661
+ longest_durable_prefix_tokens: ssdStore.longestDurablePrefixTokens,
2776
2662
  },
2777
2663
  } : {}),
2778
2664
  response_store: {
@@ -2839,8 +2725,6 @@ export function createServer(
2839
2725
  });
2840
2726
  }
2841
2727
 
2842
- // mlx_lm.server parity: GET /health → the exact body it writes
2843
- // ('{"status": "ok"}', note the space) so byte-for-byte health checks pass.
2844
2728
  // Engine-mode admin (unix-socket children only — never exposed on
2845
2729
  // TCP): drain = quiesce the gateway + demote the whole prompt cache
2846
2730
  // to the SSD tier. The pool calls this before evicting a model
@@ -2856,207 +2740,6 @@ export function createServer(
2856
2740
  return Response.json({ drained: true, demotions: promptCache.demotions });
2857
2741
  }
2858
2742
 
2859
- if (url.pathname === "/health" && request.method === "GET") {
2860
- return new Response('{"status": "ok"}', {
2861
- headers: { "content-type": "application/json" },
2862
- });
2863
- }
2864
-
2865
- // GET /v1/models — the served model FIRST (with our capability extras),
2866
- // then every other servable model the registry knows (mlx-lm scans the
2867
- // HF cache here; our registry is that scan, filtered to supported
2868
- // architectures). GET /v1/models/<id> filters to that id — same list
2869
- // shape, matching mlx_lm.server's handle_models_request.
2870
- if (
2871
- (url.pathname === "/v1/models" || url.pathname.startsWith("/v1/models/")) &&
2872
- request.method === "GET"
2873
- ) {
2874
- const filterId = url.pathname.length > "/v1/models/".length - 1
2875
- ? decodeURIComponent(url.pathname.slice("/v1/models/".length))
2876
- : null;
2877
- const created = Math.floor(startedAt / 1000);
2878
- // Model-author sampling defaults (generation_config.json), resolved once
2879
- // at load (web-ui-pass-plan.md #14 groundwork): lets a client (the web
2880
- // chat's sampling popover, external pi) show the SERVED model's actual
2881
- // recommended values instead of one hardcoded shape.
2882
- const genDefaultsOut = {
2883
- temperature: ctx.genDefaults.temperature ?? null,
2884
- top_p: ctx.genDefaults.topP ?? null,
2885
- top_k: ctx.genDefaults.topK ?? null,
2886
- };
2887
- const data: Array<Record<string, unknown>> = [{
2888
- id: ctx.modelId, object: "model", created, owned_by: "mlx-bun",
2889
- context_window:
2890
- ctx.glmMemoryPlan?.contextTokens ??
2891
- ctx.model.config.text.maxPositionEmbeddings,
2892
- // Capability flags for clients (CLI/external pi) that build a
2893
- // provider from discovery — `reasoning` gates the thinking toggle,
2894
- // `vision` the image input declaration, `audio` whether
2895
- // `input_audio` content parts are accepted (tower loaded or lazily
2896
- // loadable; same signals the web embed uses).
2897
- reasoning: ctx.template.supportsThinking,
2898
- vision: !!(ctx.vision || ctx.loadVision),
2899
- audio: !!(ctx.audio || ctx.loadAudio),
2900
- batch_mode: gateway.batchMode,
2901
- tools: true,
2902
- structured_output: true,
2903
- embeddings: isEmbeddingModel(ctx.model),
2904
- adapters: !(ctx.model instanceof Glm52Model),
2905
- training: !(ctx.model instanceof Glm52Model),
2906
- dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
2907
- mtp: ctx.draft?.provider.id === "glm52-native-mtp",
2908
- capabilities: {
2909
- chat_completions: true,
2910
- text_completions: true,
2911
- anthropic_messages: true,
2912
- responses: true,
2913
- streaming: true,
2914
- tools: true,
2915
- structured_output: true,
2916
- logprobs: true,
2917
- embeddings: isEmbeddingModel(ctx.model),
2918
- vision: !!(ctx.vision || ctx.loadVision),
2919
- audio: !!(ctx.audio || ctx.loadAudio),
2920
- adapters: !(ctx.model instanceof Glm52Model),
2921
- training: !(ctx.model instanceof Glm52Model),
2922
- },
2923
- gen_defaults: genDefaultsOut,
2924
- }];
2925
- try {
2926
- const { Registry, visionCapable } = await import("./registry");
2927
- const { supportTier } = await import("./model/support");
2928
- const reg = new Registry();
2929
- try {
2930
- if (reg.list().length === 0) await reg.scan();
2931
- // Canonical rows only — a repo with N snapshots is ONE model id.
2932
- for (const m of reg.listCanonical()) {
2933
- if (m.repoId === ctx.modelId) continue;
2934
- const tier = supportTier(m.modelType, m.repoId);
2935
- if (tier === null) continue;
2936
- data.push({
2937
- id: m.repoId, object: "model", created,
2938
- vision: visionCapable(m), tier,
2939
- });
2940
- }
2941
- } finally {
2942
- reg.close();
2943
- }
2944
- } catch { /* registry unavailable → served model only */ }
2945
- return Response.json({
2946
- object: "list",
2947
- data: filterId ? data.filter((m) => m.id === filterId) : data,
2948
- });
2949
- }
2950
-
2951
- // OpenAI embeddings API. Works when the SERVED model is an embedding model
2952
- // (plain Qwen3 / Qwen3-Embedding) — consistent with the single-model server
2953
- // design: `mlx-bun serve <embedding-model>` to use this. Optional non-standard
2954
- // `instruction` applies Qwen3-Embedding's query format. Embedding is a pure
2955
- // forward (no decode loop / gateway), so it runs inline.
2956
- if (url.pathname === "/v1/embeddings" && request.method === "POST") {
2957
- if (!isEmbeddingModel(ctx.model))
2958
- return Response.json({
2959
- error: {
2960
- message: `served model "${ctx.modelId}" is not an embedding model; ` +
2961
- `serve an embedding model (e.g. Qwen3-Embedding) to use /v1/embeddings`,
2962
- type: "invalid_request_error",
2963
- },
2964
- }, { status: 400 });
2965
- let body: { input?: string | string[]; instruction?: string };
2966
- try {
2967
- body = (await request.json()) as typeof body;
2968
- } catch {
2969
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
2970
- }
2971
- const inputs = Array.isArray(body.input) ? body.input : body.input != null ? [body.input] : [];
2972
- if (inputs.length === 0 || !inputs.every((s) => typeof s === "string"))
2973
- return Response.json({
2974
- error: { message: "`input` must be a string or array of strings", type: "invalid_request_error" },
2975
- }, { status: 400 });
2976
- const instruction = typeof body.instruction === "string" ? body.instruction : undefined;
2977
- const results = embedMany(ctx.model, ctx.tokenizer, inputs, instruction);
2978
- let totalTokens = 0;
2979
- const data = results.map((r, index) => {
2980
- totalTokens += r.tokens;
2981
- return { object: "embedding", index, embedding: Array.from(r.vector) };
2982
- });
2983
- return Response.json({
2984
- object: "list",
2985
- data,
2986
- model: ctx.modelId,
2987
- usage: { prompt_tokens: totalTokens, total_tokens: totalTokens },
2988
- });
2989
- }
2990
-
2991
- // Adapter admin (port of optiq registry semantics): list / mount /
2992
- // unmount. Mount and unmount go through the generation queue so
2993
- // they never race an in-flight forward pass.
2994
- if (url.pathname === "/v1/adapters/available" && request.method === "GET") {
2995
- // On-disk adapters that can be mounted (the chat selector's source).
2996
- // Every on-disk adapter is returned — the web chat routing chip (§5.2)
2997
- // needs incompatible entries visible-but-grayed, not silently dropped,
2998
- // so a user understands "10 adapters exist, 2 apply here" rather than
2999
- // seeing a shorter list with no explanation. `compatible` = the
3000
- // adapter's recorded base repo id matches the served model (compared
3001
- // on the bare name, lenient about the org); an adapter with no
3002
- // recorded base is treated as compatible (mount validates it for
3003
- // real). `mounted` flags ones already loaded so the UI auto-loads on
3004
- // select only if needed.
3005
- const { homedir } = await import("node:os");
3006
- const stores = [
3007
- `${homedir()}/.cache/mlx-bun-finetunes`,
3008
- `${homedir()}/.cache/mlx-bun/adapters`,
3009
- ];
3010
- const mounted = new Set(ctx.adapters.list().map((a) => a.id));
3011
- const bareName = (s: string) => s.split("/").pop()!.toLowerCase();
3012
- const servedName = bareName(ctx.modelId);
3013
- const adapters = (await listAvailableAdapters(stores))
3014
- .map((a) => ({
3015
- id: a.id, path: a.path, rank: a.rank, scale: a.scale,
3016
- base_model: a.baseModel, mounted: mounted.has(a.id),
3017
- compatible: a.baseModel == null || bareName(a.baseModel) === servedName,
3018
- }));
3019
- return Response.json({ adapters });
3020
- }
3021
- if (url.pathname === "/v1/adapters" && request.method === "GET") {
3022
- return Response.json({
3023
- adapters: ctx.adapters.list().map((a) => ({
3024
- id: a.id, path: a.path, rank: a.rank, scale: a.scale,
3025
- size_bytes: a.sizeBytes, mounted_layers: a.mountedLayers,
3026
- ram_bytes: a.ramBytes,
3027
- })),
3028
- });
3029
- }
3030
- if (url.pathname === "/v1/adapters" && request.method === "POST") {
3031
- let body: { id?: string; path?: string };
3032
- try {
3033
- body = (await request.json()) as typeof body;
3034
- } catch {
3035
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3036
- }
3037
- if (!body.id || !body.path)
3038
- return Response.json({ error: { message: "id and path required" } }, { status: 400 });
3039
- try {
3040
- // Under the gateway lock: mount/unmount mutate the shared adapter
3041
- // registry (unmount disposes arrays a running generation could still
3042
- // hold) — one mutual-exclusion domain with generation (D3).
3043
- const info = await gateway.runExclusive(() => ctx.adapters.mount(body.id!, body.path!));
3044
- return Response.json({
3045
- id: info.id, mounted_layers: info.mountedLayers,
3046
- rank: info.rank, scale: info.scale, ram_bytes: info.ramBytes,
3047
- });
3048
- } catch (e) {
3049
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3050
- }
3051
- }
3052
- if (url.pathname.startsWith("/v1/adapters/") && request.method === "DELETE") {
3053
- const id = decodeURIComponent(url.pathname.slice("/v1/adapters/".length));
3054
- const removed = await gateway.runExclusive(async () => ctx.adapters.unmount(id));
3055
- return removed > 0
3056
- ? Response.json({ id, removed_layers: removed })
3057
- : Response.json({ error: { message: `adapter ${id} not mounted` } }, { status: 404 });
3058
- }
3059
-
3060
2743
  // ---- Curve Designer: POST /signal {prompt} → next-token histogram over the curve's x-axis ----
3061
2744
  // One prefill forward; bins the real log-prob distribution so the editor can draw the
3062
2745
  // signal under the curve (you shape where the tokens actually are).
@@ -3145,15 +2828,27 @@ export function createServer(
3145
2828
  // translates its body into this shape and the Response back —
3146
2829
  // generation, tools, vision, stop sequences, prompt cache, and
3147
2830
  // admission control all live here exactly once.
3148
- 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> => {
3149
2838
  if (!Array.isArray(body.messages) || body.messages.length === 0)
3150
2839
  return Response.json({ error: { message: "messages required" } }, { status: 400 });
3151
2840
  // mlx-lm validates logprobs params up front (ValueError → 400)
3152
2841
  const lpParamError = validateLogprobsParams(body);
3153
2842
  if (lpParamError)
3154
2843
  return Response.json({ error: { message: lpParamError } }, { status: 400 });
3155
-
3156
- 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");
3157
2852
  const created = Math.floor(Date.now() / 1000);
3158
2853
  const tools =
3159
2854
  body.tool_choice === "none" ? null : (body.tools?.length ? body.tools : null);
@@ -3169,6 +2864,13 @@ export function createServer(
3169
2864
  (p: any) => p.type === "input_audio" || p.type === "audio" || p.type === "audio_url",
3170
2865
  ),
3171
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
+ );
3172
2874
  let promptIds: number[];
3173
2875
  let stableLen: number | null = null;
3174
2876
  // Whether the prompt primed an open <think> (Qwen3.5/MiniCPM5 thinking
@@ -3177,6 +2879,7 @@ export function createServer(
3177
2879
  let startInThinking = false;
3178
2880
  let vision: Parameters<typeof runGeneration>[3];
3179
2881
  let diffusionPixels: import("./mlx/array").MlxArray | null = null;
2882
+ const ownership = new RequestOwnership();
3180
2883
  // Grammar-constrained decoding (src/grammar.ts). Compile BEFORE prompt
3181
2884
  // rendering: on the degrade path (compile failed but a constraint was
3182
2885
  // requested) inject a system message instructing valid JSON so the
@@ -3190,27 +2893,30 @@ export function createServer(
3190
2893
  body.structured_outputs != null;
3191
2894
  if (grammarReq) {
3192
2895
  const g = await compileGrammarForRequest(body);
3193
- grammarCtrl = g.controller;
2896
+ grammarCtrl = ownership.own(g.controller);
3194
2897
  if (!g.controller && g.degradeHint) {
3195
2898
  const degraded = applyGrammarDegrade(body, g.degradeHint);
3196
2899
  grammarWarning = degraded.warning;
3197
2900
  body = degraded.body;
3198
2901
  }
3199
2902
  }
3200
- // Free what a rejected request has already allocated (grammar WASM
3201
- // matcher/compiled-grammar, vision embeddings, diffusion pixels).
3202
- // Every early return between here and gateway.run() must call this —
3203
- // generate()/the gateway only take disposal ownership once running
3204
- // (leaked one matcher per SSRF-blocked-media + response_format
3205
- // request before the 2026-07-07 sweep).
3206
- const disposeRejected = () => {
3207
- grammarCtrl?.dispose();
3208
- vision?.embeddings.dispose();
3209
- vision?.imageMask?.dispose();
3210
- vision?.multimodalMask?.dispose();
3211
- diffusionPixels?.dispose();
2903
+ const rejectBeforeRun = (response: Response): Response => {
2904
+ ownership.dispose();
2905
+ return response;
3212
2906
  };
3213
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
+ }
3214
2920
  if (hasAudio) {
3215
2921
  // Audio (and MIXED image+audio) input — A4 of
3216
2922
  // docs/design/audio-input-plan.md. One buildMultimodalPrompt call
@@ -3220,8 +2926,7 @@ export function createServer(
3220
2926
  // requests without the media, getVisionTower's contract).
3221
2927
  const audioTower = getAudioTower(ctx);
3222
2928
  if (!audioTower || !ctx.audioTokenIds) {
3223
- disposeRejected();
3224
- return Response.json(
2929
+ return rejectBeforeRun(Response.json(
3225
2930
  {
3226
2931
  error: {
3227
2932
  message:
@@ -3231,7 +2936,7 @@ export function createServer(
3231
2936
  },
3232
2937
  },
3233
2938
  { status: 400 },
3234
- );
2939
+ ));
3235
2940
  }
3236
2941
  let visionSide:
3237
2942
  | { tower: VisionEncoder; tokenIds: VisionTokenIds }
@@ -3239,10 +2944,9 @@ export function createServer(
3239
2944
  if (hasImages) {
3240
2945
  const tower = getVisionTower(ctx);
3241
2946
  if (!tower) {
3242
- disposeRejected();
3243
- return Response.json(
2947
+ return rejectBeforeRun(Response.json(
3244
2948
  { error: { message: "model has no vision sidecar" } }, { status: 400 },
3245
- );
2949
+ ));
3246
2950
  }
3247
2951
  visionSide = { tower, tokenIds: ctx.visionTokenIds };
3248
2952
  }
@@ -3277,37 +2981,62 @@ export function createServer(
3277
2981
  // forwardEmbeddings path). v1 supports a single image.
3278
2982
  const dm = ctx.model;
3279
2983
  if (!dm.visionTower) {
3280
- disposeRejected();
3281
- return Response.json(
2984
+ return rejectBeforeRun(Response.json(
3282
2985
  { error: { message: "this checkpoint has no vision tower" } }, { status: 400 },
3283
- );
2986
+ ));
3284
2987
  }
3285
2988
  const { messages, images } = await extractImages(normalizeMessages(body.messages));
3286
2989
  if (images.length !== 1) {
3287
- disposeRejected();
3288
- return Response.json(
2990
+ return rejectBeforeRun(Response.json(
3289
2991
  { error: { message: "DiffusionGemma image input supports exactly one image" } },
3290
2992
  { status: 400 },
3291
- );
2993
+ ));
3292
2994
  }
3293
2995
  const rendered = ctx.template.render(messages, { tools, addGenerationPrompt: true });
3294
2996
  const rawIds = ctx.tokenizer.encode(rendered, /* addSpecialTokens */ false);
3295
2997
  const { pixels, softTokens } = await dm.visionTower.preprocess(images[0]!);
2998
+ diffusionPixels = ownership.own(pixels);
3296
2999
  promptIds = spliceImageTokens(rawIds, [softTokens], {
3297
3000
  image: ctx.visionTokenIds.imageTokenId,
3298
3001
  boi: ctx.visionTokenIds.boiTokenId,
3299
3002
  eoi: ctx.visionTokenIds.eoiTokenId,
3300
3003
  });
3301
- 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 };
3302
3032
  } else if (hasImages) {
3303
3033
  // Loads (and caches) the tower on first image request — text-only
3304
3034
  // sessions never pay for it.
3305
3035
  const tower = getVisionTower(ctx);
3306
3036
  if (!tower) {
3307
- disposeRejected();
3308
- return Response.json(
3037
+ return rejectBeforeRun(Response.json(
3309
3038
  { error: { message: "model has no vision sidecar" } }, { status: 400 },
3310
- );
3039
+ ));
3311
3040
  }
3312
3041
  const { messages, images } = await extractImages(normalizeMessages(body.messages));
3313
3042
  // The tower is only ever non-null for Gemma4 (sidecar gate in
@@ -3325,12 +3054,15 @@ export function createServer(
3325
3054
  stableLen = -1; // marker: chat path, probe lazily below
3326
3055
  }
3327
3056
  } catch (e) {
3328
- disposeRejected();
3329
- return Response.json(
3057
+ return rejectBeforeRun(Response.json(
3330
3058
  { error: { message: `prompt build failed: ${(e as Error).message}` } },
3331
3059
  { status: 400 },
3332
- );
3060
+ ));
3333
3061
  }
3062
+ ownership.own(vision?.embeddings);
3063
+ ownership.own(vision?.imageMask);
3064
+ ownership.own(vision?.multimodalMask);
3065
+ ownership.own(diffusionPixels);
3334
3066
  let options: ReturnType<typeof toOptions>;
3335
3067
  try {
3336
3068
  options = toOptions(body);
@@ -3348,105 +3080,81 @@ export function createServer(
3348
3080
  }
3349
3081
  } catch (e) {
3350
3082
  // bad logit_bias (non-numeric keys/values) — mlx-lm's coercion error
3351
- disposeRejected();
3352
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3083
+ return rejectBeforeRun(
3084
+ Response.json({ error: { message: (e as Error).message } }, { status: 400 }),
3085
+ );
3353
3086
  }
3354
3087
  if (diffusionPixels) options.visionPixels = diffusionPixels;
3355
3088
  // Attach the compiled grammar controller (null when no constraint /
3356
3089
  // degrade — generate() runs the unmasked fast pipelined loop).
3357
3090
  if (grammarCtrl) options.grammar = grammarCtrl;
3358
- // Admission: reject what cannot finish within the memory budget
3359
- // (the GPU OOM it would otherwise hit is uncatchable and kills
3360
- // the process — Phase 6 finding).
3361
3091
  const requestedMaxTokens = options.maxTokens ?? 1024;
3362
- const requiredCtx = promptIds.length + requestedMaxTokens;
3363
- const contextDecision = admitRequestContext(
3364
- promptIds.length,
3365
- requestedMaxTokens,
3366
- admission.maxSafeContext,
3367
- ctx.glmMemoryPlan !== null,
3368
- );
3369
- if (!contextDecision) {
3370
- disposeRejected();
3371
- return Response.json(
3372
- {
3373
- error: {
3374
- message:
3375
- `request needs ${requiredCtx} tokens of context ` +
3376
- `(prompt ${promptIds.length} + max_tokens ${requestedMaxTokens}) but the ` +
3377
- `memory budget caps safe context at ${admission.maxSafeContext} — ` +
3378
- `shorten the prompt or lower max_tokens`,
3379
- type: "memory_admission",
3380
- code: "context_over_budget",
3381
- },
3382
- },
3383
- { status: 400 },
3384
- );
3385
- }
3386
- options.maxTokens = contextDecision.maxTokens;
3092
+ let adapterIds: string[];
3387
3093
  try {
3388
3094
  // A request's explicit `adapter` (incl. "none") wins over the
3389
3095
  // startup default from `serve --adapter <dir>`.
3390
- const adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3391
- if (adapterIds.length) options.adapters = adapterIds;
3096
+ adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3392
3097
  } catch (e) {
3393
- disposeRejected();
3394
- 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
+ );
3395
3101
  }
3396
3102
 
3397
- // logprobs capture: non-stream only — stream chunks never carry
3398
- // logprobs (mirroring mlx_lm.server, whose streaming generate_response
3399
- // calls pass no token_logprobs/top_tokens), so streaming requests skip
3400
- // the capture cost entirely and stay batchable.
3401
3103
  const wantLogprobs = body.logprobs === true;
3402
3104
  const topLogprobs =
3403
3105
  typeof body.top_logprobs === "number" && body.top_logprobs > 0
3404
3106
  ? body.top_logprobs : 0;
3405
- const captureLogprobs = !body.stream && (wantLogprobs || topLogprobs > 0);
3406
- if (captureLogprobs) {
3407
- options.logprobs = wantLogprobs;
3408
- 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 });
3409
3156
  }
3410
-
3411
- // What lane this request takes (vision/audio media / adapters /
3412
- // logprobs / seed / explicit kv-quant / a mounted draft → serial;
3413
- // sampler extras, repetition penalty, and grammar all BATCH — see
3414
- // willBatch). `vision` is the embeddings-prefill payload for BOTH
3415
- // media kinds, so hasVision routes audio serial too — batching is a
3416
- // mode, not a fallback, and batched audio has no oracle (§3.2).
3417
- const shape = {
3418
- hasVision: !!vision,
3419
- hasAdapters: !!options.adapters?.length,
3420
- hasRepetitionPenalty: !!options.repetitionPenalty,
3421
- // Informational since 2026-07-02: per-row logits processors batch;
3422
- // willBatch no longer gates on these fields.
3423
- hasLogitsExtras: !!(
3424
- options.minP || options.xtcProbability || options.logitBias ||
3425
- options.presencePenalty || options.frequencyPenalty
3426
- ),
3427
- wantsLogprobs: captureLogprobs,
3428
- userSeed: body.seed !== undefined,
3429
- kvQuant: !!(options.kvConfig?.length || options.kvBits),
3430
- turboQuant: !!options.turboQuant,
3431
- // grammarCtrl is null on the degrade path (prompt injection) —
3432
- // those stay batchable. A real controller batches via per-row
3433
- // matchers (MLX_BUN_GRAMMAR_BATCH=0 forces it serial).
3434
- hasGrammar: !!grammarCtrl,
3435
- hasDraft: !!ctx.draft,
3436
- };
3437
- const batched = gateway.willBatch(shape);
3438
- if (process.env.MLX_BUN_LANE_DEBUG === "1")
3439
- console.error(`[lane] batched=${batched} shape=${JSON.stringify(shape)} t=${Date.now() % 100000}`);
3440
- // Per-turn lane (docs/design/web-chat-redesign.md §2.3 caveat / risk #5):
3441
- // reported on usage AND recorded in the in-process lane registry (keyed
3442
- // by this response's `id`) so pi-web's WS bridge — which the pi SDK's
3443
- // usage parsing can't carry a custom field through — can correlate it
3444
- // via AssistantMessage.responseId. batched wins over hasDraft (mutually
3445
- // exclusive by construction: willBatch already excludes hasDraft), so
3446
- // the only ambiguity is serial vs serial+spec, resolved once s.spec is
3447
- // known post-generation.
3448
- const lane: Lane = batched ? "batched" : shape.hasDraft ? "serial+spec" : "serial";
3449
- recordLane(id, lane);
3157
+ closePromptPrepare?.();
3450
3158
 
3451
3159
  if (body.stream) {
3452
3160
  const streamAbort = new AbortController();
@@ -3459,6 +3167,13 @@ export function createServer(
3459
3167
  if (!generationSignal.aborted)
3460
3168
  controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3461
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";
3462
3177
  const chunk = (delta: Record<string, unknown>, finish: string | null) => ({
3463
3178
  id, object: "chat.completion.chunk", created, model: ctx.modelId,
3464
3179
  choices: [{ index: 0, delta, finish_reason: finish }],
@@ -3468,92 +3183,65 @@ export function createServer(
3468
3183
  // The gateway owns lane selection + GPU exclusivity; this body
3469
3184
  // runs per-request (concurrently in batched mode, each writing
3470
3185
  // its own SSE stream — the per-row fan-out).
3471
- send(chunk({ role: "assistant", content: "" }, null));
3472
- const router = toolRouter(tools);
3473
- const stopper = new StopMatcher(options.stopSequences);
3474
- // <think>-text splitting is only for text-marker models; gemma's
3475
- // reasoning is already split at the token level by the router.
3476
- const thinking = new ThinkingTagSplitter(ctx.template.thinkingFormat === "think-tag", startInThinking);
3477
- // Serial decode is an unbroken microtask chain (FFI + generator
3478
- // resumes) — without a macrotask hop, Bun never services the
3479
- // socket and the whole SSE response flushes in one burst at the
3480
- // end (Phase 15: "687k tok/s decode"). Hopping EVERY token cost
3481
- // ~23% decode; rate-limited to ≥25 ms keeps the flush smooth and
3482
- // hides behind the next GPU step. Batched mode doesn't need it —
3483
- // the scheduler yields to the event loop between steps.
3484
- let lastFlush = performance.now();
3485
- const s = await gateway.run(promptIds, options, (token) => {
3486
- const rawContent = router.push(token);
3487
- // gemma-channel reasoning, split at the token level by the router
3488
- const rReason = router.takeReasoning();
3489
- if (rReason) send(chunk({ reasoning: rReason }, null));
3490
- const text = stopper.push(rawContent);
3491
- const parts = thinking.push(text);
3492
- if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
3493
- if (parts.content) send(chunk({ content: parts.content }, null));
3494
- if (stopper.stopped) return false; // halt generation
3495
- if (!batched && (parts.content || parts.reasoning || rReason)) {
3496
- const now = performance.now();
3497
- if (now - lastFlush >= 25) {
3498
- lastFlush = now;
3499
- return new Promise<void>((r) => setImmediate(r));
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));
3500
3206
  }
3501
3207
  }
3502
- }, vision, shape, generationSignal);
3503
- if (generationSignal.aborted) return;
3504
- // a stop match discards everything from the match on,
3505
- // including text still held by the decoders
3506
- let tail = "";
3507
- if (!stopper.stopped) {
3508
- const flushed = router.flush();
3509
- const rReason = router.takeReasoning();
3510
- if (rReason) send(chunk({ reasoning: rReason }, null));
3511
- tail = stopper.push(flushed);
3512
- if (!stopper.stopped) tail += stopper.flush();
3513
- }
3514
- if (tail) {
3515
- const parts = thinking.push(tail);
3516
- if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
3517
- if (parts.content) send(chunk({ content: parts.content }, null));
3518
- }
3519
- {
3520
- const parts = thinking.flush();
3521
- if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
3522
- if (parts.content) send(chunk({ content: parts.content }, null));
3523
- }
3524
- const toolCalls = router.toolCalls();
3525
- if (toolCalls.length) {
3526
- send(chunk({
3527
- tool_calls: toolCalls.map((tc, i) => ({ index: i, ...tc })),
3528
- }, null));
3529
- }
3530
- const finish = toolCalls.length
3531
- ? "tool_calls"
3532
- : stopper.stopped ? "stop"
3533
- : s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
3534
- // Refine serial vs serial+spec now that s.spec is known (a mounted
3535
- // draft can still decode zero speculative tokens on a very short
3536
- // reply) and re-record for pi-web's WS correlation.
3537
- const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
3538
- if (finalLane !== lane) recordLane(id, finalLane);
3539
- send({
3540
- ...chunk({}, finish),
3541
- usage: {
3542
- prompt_tokens: s.promptTokens,
3543
- completion_tokens: s.generatedTokens,
3544
- total_tokens: s.promptTokens + s.generatedTokens,
3545
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3546
- lane: finalLane,
3547
- ...(s.spec ? { speculation: s.spec } : {}),
3548
- },
3208
+ };
3209
+ const summary = await completionExecutor.execute(prepared, {
3210
+ signal: generationSignal,
3211
+ trace,
3212
+ onEvents: sendEvents,
3213
+ ...(streamProtocol
3214
+ ? { onUsageProgress: (usage) => { latestUsage = usage; } }
3215
+ : {}),
3549
3216
  });
3550
- // bare sentinel per the OpenAI spec — JSON.stringify would
3551
- // 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.
3552
3224
  controller.enqueue(enc.encode("data: [DONE]\n\n"));
3225
+ }
3226
+ trace?.mark("response.final_write");
3553
3227
  } catch (e) {
3554
- if (!generationSignal.aborted)
3555
- 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
+ }
3556
3243
  } finally {
3244
+ trace?.finish(traceOutcome);
3557
3245
  if (!cancelled) {
3558
3246
  if (generationSignal.aborted) controller.error(generationSignal.reason);
3559
3247
  else controller.close();
@@ -3578,85 +3266,53 @@ export function createServer(
3578
3266
 
3579
3267
  try {
3580
3268
  {
3581
- const router = toolRouter(tools);
3582
- const stopper = new StopMatcher(options.stopSequences);
3583
- const thinking = new ThinkingTagSplitter(ctx.template.thinkingFormat === "think-tag", startInThinking);
3584
- // mlx-lm collects logprobs across EVERY generated token (reasoning
3585
- // and tool tokens included), not just visible content — same here.
3586
- const lpc = captureLogprobs
3587
- ? new LogprobsCollector(wantLogprobs, topLogprobs, (tid) => ctx.tokenizer.idToToken(tid))
3588
- : null;
3589
- let content = "";
3590
- let reasoning = "";
3591
- const s = await gateway.run(promptIds, options, (token, lpInfo) => {
3592
- lpc?.push(token, lpInfo);
3593
- const rawContent = router.push(token);
3594
- reasoning += router.takeReasoning(); // gemma-channel thinking
3595
- const parts = thinking.push(stopper.push(rawContent));
3596
- content += parts.content;
3597
- reasoning += parts.reasoning;
3598
- if (stopper.stopped) return false; // halt generation
3599
- }, vision, shape, signal);
3600
- if (!stopper.stopped) {
3601
- const flushed = router.flush();
3602
- reasoning += router.takeReasoning();
3603
- let tail = stopper.push(flushed);
3604
- if (!stopper.stopped) tail += stopper.flush();
3605
- const parts = thinking.push(tail);
3606
- content += parts.content;
3607
- reasoning += parts.reasoning;
3608
- }
3609
- {
3610
- const parts = thinking.flush();
3611
- content += parts.content;
3612
- reasoning += parts.reasoning;
3613
- }
3614
- const toolCalls = router.toolCalls();
3615
- const finish = toolCalls.length
3616
- ? "tool_calls"
3617
- : stopper.stopped ? "stop"
3618
- : s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
3619
- const logprobsBlock = lpc?.payload() ?? null;
3620
- // Refine serial vs serial+spec now that s.spec is known; re-record
3621
- // for pi-web's WS correlation (see the streaming branch above).
3622
- const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
3623
- if (finalLane !== lane) recordLane(id, finalLane);
3624
- return Response.json({
3269
+ const summary = await completionExecutor.execute(prepared, { signal, trace });
3270
+ trace?.mark("response.final_write");
3271
+ const response = Response.json({
3625
3272
  id, object: "chat.completion", created, model: ctx.modelId,
3626
3273
  choices: [{
3627
3274
  index: 0,
3628
3275
  message: {
3629
3276
  role: "assistant",
3630
- content: content || (toolCalls.length ? null : ""),
3631
- ...(reasoning ? { reasoning } : {}),
3632
- ...(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 } : {}),
3633
3280
  },
3634
- ...(logprobsBlock ? { logprobs: logprobsBlock } : {}),
3635
- finish_reason: finish,
3281
+ ...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
3282
+ finish_reason: summary.finishReason,
3636
3283
  }],
3637
- usage: {
3638
- prompt_tokens: s.promptTokens,
3639
- completion_tokens: s.generatedTokens,
3640
- total_tokens: s.promptTokens + s.generatedTokens,
3641
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3642
- lane: finalLane,
3643
- ...(s.spec ? { speculation: s.spec } : {}),
3644
- },
3284
+ usage: completionUsage(summary),
3645
3285
  }, grammarWarning ? { headers: { Warning: grammarWarning } } : undefined);
3286
+ trace?.finish("success");
3287
+ return response;
3646
3288
  }
3647
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}`);
3648
3294
  return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
3649
3295
  }
3650
3296
  };
3651
3297
 
3652
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");
3653
3306
  let body: ChatRequest;
3654
3307
  try {
3655
3308
  body = (await request.json()) as ChatRequest;
3656
3309
  } catch {
3310
+ closeBodyParse?.();
3311
+ trace?.finish("error", { stage: "body_parse" });
3657
3312
  return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3658
3313
  }
3659
- return handleChat(body, request.signal);
3314
+ closeBodyParse?.();
3315
+ return handleChat(body, request.signal, undefined, id, trace);
3660
3316
  }
3661
3317
 
3662
3318
  // Raw text completion (mlx_lm.server's /v1/completions, request_type
@@ -3666,12 +3322,23 @@ export function createServer(
3666
3322
  // GenerationGateway + admission + adapter path as chat; no tool router
3667
3323
  // or thinking splitter (raw text in, raw text out).
3668
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");
3669
3332
  let body: TextCompletionRequest;
3670
3333
  try {
3671
3334
  body = (await request.json()) as TextCompletionRequest;
3672
3335
  } catch {
3336
+ closeBodyParse?.();
3337
+ trace?.finish("error", { stage: "body_parse" });
3673
3338
  return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3674
3339
  }
3340
+ closeBodyParse?.();
3341
+ const closePromptPrepare = trace?.begin("request.prompt_prepare");
3675
3342
  if (typeof body.prompt !== "string" || body.prompt.length === 0)
3676
3343
  return Response.json(
3677
3344
  {
@@ -3686,9 +3353,9 @@ export function createServer(
3686
3353
  const lpParamError = validateLogprobsParams(body);
3687
3354
  if (lpParamError)
3688
3355
  return Response.json({ error: { message: lpParamError } }, { status: 400 });
3689
- const id = `cmpl-${crypto.randomUUID()}`;
3690
3356
  const created = Math.floor(Date.now() / 1000);
3691
3357
  const promptIds = ctx.tokenizer.encode(body.prompt);
3358
+ const ownership = new RequestOwnership();
3692
3359
  let options: ReturnType<typeof toOptions>;
3693
3360
  try {
3694
3361
  options = toOptions(body as unknown as ChatRequest);
@@ -3708,85 +3375,64 @@ export function createServer(
3708
3375
  body.structured_outputs != null;
3709
3376
  if (textGrammarReq) {
3710
3377
  const g = await compileGrammarForRequest(body as unknown as ChatRequest);
3711
- if (g.controller) options.grammar = g.controller;
3378
+ if (g.controller) options.grammar = ownership.own(g.controller);
3712
3379
  else if (g.degradeHint)
3713
3380
  textGrammarWarning =
3714
3381
  `grammar not enforced: ${g.degradeHint} - no prompt injection on /v1/completions`;
3715
3382
  }
3716
- // Mirrors the chat lane: a real controller (not the degrade path)
3717
- // shapes the request for per-row grammar batching.
3718
- const textGrammarCtrl = options.grammar ?? null;
3719
3383
  // mlx_lm.server's default max_tokens is 512 (its --max-tokens CLI
3720
3384
  // default). The chat lane's very generous default is wrong for raw
3721
3385
  // completion: with no template an EOS may never come.
3722
- options.maxTokens = body.max_completion_tokens ?? body.max_tokens ??
3386
+ const requestedMaxTokens = body.max_completion_tokens ?? body.max_tokens ??
3723
3387
  defaultGeneratedTokens ?? 512;
3724
- const requestedMaxTokens = options.maxTokens;
3725
- const requiredCtx = promptIds.length + requestedMaxTokens;
3726
- const contextDecision = admitRequestContext(
3727
- promptIds.length,
3728
- requestedMaxTokens,
3729
- admission.maxSafeContext,
3730
- ctx.glmMemoryPlan !== null,
3731
- );
3732
- if (!contextDecision) {
3733
- // Pre-run reject: the gateway only takes grammar disposal ownership
3734
- // once run — free the WASM matcher here (chat lane: disposeRejected).
3735
- textGrammarCtrl?.dispose();
3736
- return Response.json(
3737
- {
3738
- error: {
3739
- message:
3740
- `request needs ${requiredCtx} tokens of context ` +
3741
- `(prompt ${promptIds.length} + max_tokens ${options.maxTokens}) but the ` +
3742
- `memory budget caps safe context at ${admission.maxSafeContext} — ` +
3743
- `shorten the prompt or lower max_tokens`,
3744
- type: "memory_admission",
3745
- code: "context_over_budget",
3746
- },
3747
- },
3748
- { status: 400 },
3749
- );
3750
- }
3751
- options.maxTokens = contextDecision.maxTokens;
3388
+ let adapterIds: string[];
3752
3389
  try {
3753
- const adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3754
- if (adapterIds.length) options.adapters = adapterIds;
3390
+ adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3755
3391
  } catch (e) {
3756
- textGrammarCtrl?.dispose();
3392
+ ownership.dispose();
3757
3393
  return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3758
3394
  }
3759
- // logprobs capture — same mlx-lm block as chat (generate_response is
3760
- // shared in the reference too); non-stream only, stream chunks never
3761
- // carry logprobs.
3762
3395
  const wantLogprobs = body.logprobs === true;
3763
3396
  const topLogprobs =
3764
3397
  typeof body.top_logprobs === "number" && body.top_logprobs > 0
3765
3398
  ? body.top_logprobs : 0;
3766
- const captureLogprobs = !body.stream && (wantLogprobs || topLogprobs > 0);
3767
- if (captureLogprobs) {
3768
- options.logprobs = wantLogprobs;
3769
- 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 });
3770
3434
  }
3771
- const shape = {
3772
- hasVision: false,
3773
- hasAdapters: !!options.adapters?.length,
3774
- hasRepetitionPenalty: !!options.repetitionPenalty,
3775
- hasLogitsExtras: !!(
3776
- options.minP || options.xtcProbability || options.logitBias ||
3777
- options.presencePenalty || options.frequencyPenalty
3778
- ),
3779
- wantsLogprobs: captureLogprobs,
3780
- userSeed: body.seed !== undefined,
3781
- kvQuant: !!(options.kvConfig?.length || options.kvBits),
3782
- turboQuant: !!options.turboQuant,
3783
- // /v1/completions grammar (textGrammarCtrl). Same null-on-degrade
3784
- // contract as the chat lane.
3785
- hasGrammar: !!textGrammarCtrl,
3786
- hasDraft: !!ctx.draft,
3787
- };
3788
- const finishReason = (stopped: boolean, generated: number): "stop" | "length" =>
3789
- stopped ? "stop" : generated >= (options.maxTokens ?? 512) ? "length" : "stop";
3435
+ closePromptPrepare?.();
3790
3436
 
3791
3437
  if (body.stream) {
3792
3438
  const streamAbort = new AbortController();
@@ -3803,49 +3449,39 @@ export function createServer(
3803
3449
  id, object: "text_completion", created, model: ctx.modelId,
3804
3450
  choices: [{ index: 0, text, finish_reason: finish }],
3805
3451
  });
3452
+ let wroteFirstEvent = false;
3453
+ let traceOutcome: "success" | "error" | "abort" = "success";
3806
3454
  void (async () => {
3807
3455
  try {
3808
- const decoder = new StreamDecoder(ctx.tokenizer);
3809
- const stopper = new StopMatcher(options.stopSequences);
3810
- // ≥25 ms macrotask hop, same reason as the chat lane: keep the
3811
- // serial decode loop from starving the socket (SSE bursts).
3812
- let lastFlush = performance.now();
3813
- const s = await gateway.run(promptIds, options, (token) => {
3814
- const text = stopper.push(decoder.push(token));
3815
- if (text) send(chunk(text, null));
3816
- if (stopper.stopped) return false; // halt generation
3817
- if (text) {
3818
- const now = performance.now();
3819
- if (now - lastFlush >= 25) {
3820
- lastFlush = now;
3821
- return new Promise<void>((r) => setImmediate(r));
3822
- }
3456
+ const sendEvents = (events: readonly CompletionEvent[]) => {
3457
+ if (events.length && !wroteFirstEvent) {
3458
+ wroteFirstEvent = true;
3459
+ trace?.mark("response.first_write");
3823
3460
  }
3824
- }, undefined, shape, generationSignal);
3825
- if (generationSignal.aborted) return;
3826
- if (!stopper.stopped) {
3827
- let tail = stopper.push(decoder.flush());
3828
- if (!stopper.stopped) tail += stopper.flush();
3829
- if (tail) send(chunk(tail, null));
3830
- }
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
+ });
3831
3470
  // final chunk: finish_reason + usage (mlx-lm gates usage behind
3832
3471
  // stream_options.include_usage; we always attach it, matching
3833
3472
  // our chat lane — an additive superset OpenAI clients ignore)
3834
3473
  send({
3835
- ...chunk("", finishReason(stopper.stopped, s.generatedTokens)),
3836
- usage: {
3837
- prompt_tokens: s.promptTokens,
3838
- completion_tokens: s.generatedTokens,
3839
- total_tokens: s.promptTokens + s.generatedTokens,
3840
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3841
- ...(s.spec ? { speculation: s.spec } : {}),
3842
- },
3474
+ ...chunk("", summary.finishReason),
3475
+ usage: completionUsage(summary),
3843
3476
  });
3844
3477
  controller.enqueue(enc.encode("data: [DONE]\n\n"));
3478
+ trace?.mark("response.final_write");
3845
3479
  } catch (e) {
3480
+ traceOutcome = generationSignal.aborted ? "abort" : "error";
3846
3481
  if (!generationSignal.aborted)
3847
3482
  send({ error: { message: (e as Error).message } });
3848
3483
  } finally {
3484
+ trace?.finish(traceOutcome);
3849
3485
  if (!cancelled) {
3850
3486
  if (generationSignal.aborted) controller.error(generationSignal.reason);
3851
3487
  else controller.close();
@@ -3869,39 +3505,24 @@ export function createServer(
3869
3505
  }
3870
3506
 
3871
3507
  try {
3872
- const decoder = new StreamDecoder(ctx.tokenizer);
3873
- const stopper = new StopMatcher(options.stopSequences);
3874
- const lpc = captureLogprobs
3875
- ? new LogprobsCollector(wantLogprobs, topLogprobs, (tid) => ctx.tokenizer.idToToken(tid))
3876
- : null;
3877
- let text = "";
3878
- const s = await gateway.run(promptIds, options, (token, lpInfo) => {
3879
- lpc?.push(token, lpInfo);
3880
- text += stopper.push(decoder.push(token));
3881
- if (stopper.stopped) return false; // halt generation
3882
- }, undefined, shape, request.signal);
3883
- if (!stopper.stopped) {
3884
- let tail = stopper.push(decoder.flush());
3885
- if (!stopper.stopped) tail += stopper.flush();
3886
- text += tail;
3887
- }
3888
- const logprobsBlock = lpc?.payload() ?? null;
3889
- return Response.json({
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({
3890
3514
  id, object: "text_completion", created, model: ctx.modelId,
3891
3515
  choices: [{
3892
- index: 0, text,
3893
- ...(logprobsBlock ? { logprobs: logprobsBlock } : {}),
3894
- finish_reason: finishReason(stopper.stopped, s.generatedTokens),
3516
+ index: 0, text: summary.content,
3517
+ ...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
3518
+ finish_reason: summary.finishReason,
3895
3519
  }],
3896
- usage: {
3897
- prompt_tokens: s.promptTokens,
3898
- completion_tokens: s.generatedTokens,
3899
- total_tokens: s.promptTokens + s.generatedTokens,
3900
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3901
- ...(s.spec ? { speculation: s.spec } : {}),
3902
- },
3520
+ usage: completionUsage(summary),
3903
3521
  }, textGrammarWarning ? { headers: { Warning: textGrammarWarning } } : undefined);
3522
+ trace?.finish("success");
3523
+ return response;
3904
3524
  } catch (e) {
3525
+ trace?.finish(request.signal.aborted ? "abort" : "error");
3905
3526
  return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
3906
3527
  }
3907
3528
  }
@@ -3926,7 +3547,13 @@ export function createServer(
3926
3547
  } catch (e) {
3927
3548
  return anthropicError(400, "invalid_request_error", (e as Error).message);
3928
3549
  }
3929
- 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
+ );
3930
3557
  if (!resp.ok) {
3931
3558
  const err = (await resp.json().catch(() => null)) as
3932
3559
  | { error?: { message?: string } }
@@ -3938,13 +3565,7 @@ export function createServer(
3938
3565
  );
3939
3566
  }
3940
3567
  if (anthropicBody.stream) {
3941
- return new Response(translateOpenAiSse(resp.body!, ctx.modelId), {
3942
- headers: {
3943
- "content-type": "text/event-stream",
3944
- "cache-control": "no-cache",
3945
- connection: "keep-alive",
3946
- },
3947
- });
3568
+ return resp;
3948
3569
  }
3949
3570
  return Response.json(chatJsonToAnthropic(await resp.json(), ctx.modelId));
3950
3571
  }
@@ -4007,7 +3628,22 @@ export function createServer(
4007
3628
  } catch (e) {
4008
3629
  return responsesError(400, (e as Error).message);
4009
3630
  }
4010
- 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
+ );
4011
3647
  if (!resp.ok) {
4012
3648
  const err = (await resp.json().catch(() => null)) as
4013
3649
  | { error?: { message?: string } }
@@ -4015,22 +3651,7 @@ export function createServer(
4015
3651
  return responsesError(resp.status, err?.error?.message ?? "request failed");
4016
3652
  }
4017
3653
  if (responsesBody.stream) {
4018
- const body = translateOpenAiSseToResponses(
4019
- resp.body!, ctx.modelId, prevId,
4020
- (final) =>
4021
- responseStore.put(final.id as string, {
4022
- input: capturedInput,
4023
- output: final.output as unknown[],
4024
- instructions: capturedInstructions,
4025
- }),
4026
- );
4027
- return new Response(body, {
4028
- headers: {
4029
- "content-type": "text/event-stream",
4030
- "cache-control": "no-cache",
4031
- connection: "keep-alive",
4032
- },
4033
- });
3654
+ return resp;
4034
3655
  }
4035
3656
  const responses = chatJsonToResponses(await resp.json(), ctx.modelId, prevId);
4036
3657
  responseStore.put(responses.id as string, {
@@ -4041,346 +3662,29 @@ export function createServer(
4041
3662
  return Response.json(responses);
4042
3663
  }
4043
3664
 
4044
- // --- Lab API: dataset builder + quantize + finetune + jobs -------
4045
- if (url.pathname === "/api/dataset/templates" && request.method === "GET") {
4046
- const { TEMPLATES } = await import("./dataset");
4047
- return Response.json({ templates: TEMPLATES });
4048
- }
4049
- if (url.pathname === "/api/dataset/submit" && request.method === "POST") {
4050
- const body = (await request.json().catch(() => ({}))) as {
4051
- template_id?: string; inputs?: Record<string, unknown>; model_name?: string;
4052
- };
4053
- const { getTemplate } = await import("./dataset");
4054
- if (!body.template_id || !getTemplate(body.template_id))
4055
- return Response.json({ ok: false, error: `unknown template ${body.template_id}` }, { status: 400 });
4056
- const store = await ensureJobs();
4057
- const { submitInProcess } = await import("./jobs");
4058
- const { homedir } = await import("node:os");
4059
- const safe = body.template_id.replace(/[^a-z0-9_-]/gi, "");
4060
- const outDir = `${homedir()}/.cache/mlx-bun/datasets/dataset-${safe}-${Date.now()}`;
4061
- const { jobId } = submitInProcess(store, "dataset", {
4062
- template_id: body.template_id, inputs: body.inputs ?? {},
4063
- output_dir: outDir, api_url: `http://127.0.0.1:${server.port}`,
4064
- model_name: body.model_name ?? "local",
4065
- }, outDir);
4066
- return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
4067
- }
4068
-
4069
- if (url.pathname === "/api/quantize/inspect" && request.method === "POST") {
4070
- const body = (await request.json().catch(() => ({}))) as { model_id?: string };
4071
- const { inspectModel } = await import("./quantize");
4072
- return Response.json(await inspectModel(body.model_id ?? ""));
4073
- }
4074
- // Turn an OS-picked folder into the absolute snapshot path on disk. The
4075
- // browser can't reveal a filesystem path (security) and the HF cache
4076
- // stores real bytes in blobs/ with symlinked snapshots — so we resolve by
4077
- // the folder's NAME (which encodes the repo id), never by reading files.
4078
- // No upload, no dependence on the cache's symlink layout, and a
4079
- // just-downloaded model resolves before it's ever been indexed.
4080
- if ((url.pathname === "/api/quantize/resolve-folder" || url.pathname === "/api/model/resolve-folder") && request.method === "POST") {
4081
- const body = (await request.json().catch(() => ({}))) as { folder_name?: string; rel_path?: string };
4082
- const { statSync, readdirSync, readFileSync } = await import("node:fs");
4083
- const { join } = await import("node:path");
4084
- const { homedir } = await import("node:os");
4085
- const hubRoot = process.env.HF_HUB_CACHE
4086
- ?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
4087
- const roots = [hubRoot, join(homedir(), ".cache/mlx-bun")];
4088
- const hasConfig = (d: string) => { try { return statSync(join(d, "config.json")).isFile(); } catch { return false; } };
4089
- // basename of the picked folder; the rel path's last-but-one segment is
4090
- // the <hash> dir if they drilled into a snapshot.
4091
- const folder = (body.folder_name ?? "").replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
4092
- const relSegs = (body.rel_path ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
4093
- const configDir = relSegs.length >= 2 ? relSegs[relSegs.length - 2] : "";
4094
- const repoIdOf = (modelsDir: string) => modelsDir.slice("models--".length).replaceAll("--", "/");
4095
- // Pick a repo dir's canonical snapshot: the one refs/main points at, else
4096
- // the first snapshot that actually has a config.json.
4097
- const pickSnapshot = (repoDir: string): string | null => {
4098
- const snaps = join(repoDir, "snapshots");
4099
- let head = "";
4100
- try { head = readFileSync(join(repoDir, "refs", "main"), "utf8").trim(); } catch {}
4101
- if (head && hasConfig(join(snaps, head))) return join(snaps, head);
4102
- try { for (const h of readdirSync(snaps)) if (hasConfig(join(snaps, h))) return join(snaps, h); } catch {}
4103
- return null;
4104
- };
4105
-
4106
- // (1) HF-cache folder picked — its name IS the repo id.
4107
- if (folder.startsWith("models--")) {
4108
- const p = pickSnapshot(join(hubRoot, folder));
4109
- if (p) return Response.json({ ok: true, path: p, repo_id: repoIdOf(folder) });
4110
- }
4111
- // (2) A bare <hash> snapshot folder picked directly (or carried in rel).
4112
- const hashDir = configDir || folder;
4113
- if (hashDir) {
4114
- try {
4115
- for (const repo of readdirSync(hubRoot)) {
4116
- if (!repo.startsWith("models--")) continue;
4117
- const cand = join(hubRoot, repo, "snapshots", hashDir);
4118
- if (hasConfig(cand)) return Response.json({ ok: true, path: cand, repo_id: repoIdOf(repo) });
4119
- }
4120
- } catch {}
4121
- }
4122
- // (3) Flat local snapshot under a known root (folder name == model dir).
4123
- for (const root of roots) {
4124
- if (folder && hasConfig(join(root, folder))) return Response.json({ ok: true, path: join(root, folder) });
4125
- }
4126
- // (4) Registry rescan — last resort (also covers customized cache layouts).
4127
- const { Registry } = await import("./registry");
4128
- const reg = new Registry();
4129
- try {
4130
- await reg.scan();
4131
- const all = reg.list();
4132
- const rec = (folder.startsWith("models--")
4133
- ? all.find((m) => m.repoId === repoIdOf(folder))
4134
- : undefined)
4135
- ?? all.find((m) => m.path.split("/").pop() === hashDir)
4136
- ?? all.find((m) => m.repoId.split("/").pop() === folder);
4137
- if (rec) return Response.json({ ok: true, path: rec.path, repo_id: rec.repoId, model_type: rec.modelType });
4138
- } finally {
4139
- reg.close();
4140
- }
4141
- return Response.json({
4142
- ok: false,
4143
- error: "Couldn't locate this folder on disk — paste the path instead.",
4144
- });
4145
- }
4146
- if (url.pathname === "/api/quantize/submit" && request.method === "POST") {
4147
- const body = (await request.json().catch(() => ({}))) as {
4148
- model_id?: string; bits?: number; group_size?: number;
4149
- target_bpw?: number; candidate_bits?: number[]; reference?: string;
4150
- calibration_mix?: string; n_calibration?: number;
4151
- };
4152
- if (!body.model_id)
4153
- return Response.json({ ok: false, error: "model_id required" }, { status: 400 });
4154
- const store = await ensureJobs();
4155
- const { submitSubprocess } = await import("./jobs");
4156
- const { homedir } = await import("node:os");
4157
- const { join } = await import("node:path");
4158
- const { mkdirSync, writeFileSync } = await import("node:fs");
4159
- const { createHash } = await import("node:crypto");
4160
- const bits = body.bits ?? 4, gs = body.group_size ?? 64;
4161
- // Derive org/name from the source so the quant is named readably (the
4162
- // source is usually an HF-cache snapshot path whose basename is a hash).
4163
- const snapMatch = body.model_id.match(/(models--[^/]+)\/snapshots\//);
4164
- let org = "local", name: string;
4165
- if (snapMatch) {
4166
- const parts = snapMatch[1]!.split("--"); // ["models", org, ...name]
4167
- org = parts[1] ?? "local";
4168
- name = parts.slice(2).join("--");
4169
- } else if (body.model_id.includes("/") && !body.model_id.startsWith("/") && !body.model_id.startsWith("~")) {
4170
- const seg = body.model_id.split("/"); // a repo id "org/name"
4171
- org = seg[0]!; name = seg.slice(1).join("-");
4172
- } else {
4173
- name = body.model_id.split("/").filter(Boolean).at(-1) ?? "model"; // a bare path
4174
- }
4175
- name = (name || "model").replace(/[^a-z0-9_.-]/gi, "");
4176
- org = (org || "local").replace(/[^a-z0-9_.-]/gi, "");
4177
- const suffix = body.target_bpw ? `mixed-${body.target_bpw}bpw` : `${bits}bit`;
4178
- // Write the quant INTO the HF hub cache as a normal models--org--name/
4179
- // snapshots/<hash> entry, so the standard registry scan + every other
4180
- // tool discovers it alongside downloaded models. refs/main makes it a
4181
- // well-formed cache entry.
4182
- const quantRepo = `${name}-OptiQ-${suffix}`;
4183
- const hubRoot = process.env.HF_HUB_CACHE
4184
- ?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
4185
- const repoDir = join(hubRoot, `models--${org}--${quantRepo}`);
4186
- const snapHash = createHash("sha1").update(`${org}/${quantRepo}`).digest("hex");
4187
- const outDir = join(repoDir, "snapshots", snapHash);
4188
- try {
4189
- mkdirSync(join(repoDir, "refs"), { recursive: true });
4190
- writeFileSync(join(repoDir, "refs", "main"), snapHash);
4191
- } catch {}
4192
- const { jobId } = submitSubprocess(store, "quantize", {
4193
- model_id: body.model_id, out_dir: outDir, bits, group_size: gs,
4194
- // forwarded to the mixed-precision path when target_bpw is set
4195
- target_bpw: body.target_bpw, candidate_bits: body.candidate_bits,
4196
- reference: body.reference, calibration_mix: body.calibration_mix,
4197
- n_calibration: body.n_calibration,
4198
- }, outDir, {
4199
- // The model is in the HF cache now; drop the Library cache so the next
4200
- // poll rescans and shows it — no `mlx-bun scan`, no restart.
4201
- onComplete: () => { libraryCache = null; },
4202
- });
4203
- return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
4204
- }
4205
-
4206
- if (url.pathname === "/api/finetune/inspect-dataset" && request.method === "POST") {
4207
- const body = (await request.json().catch(() => ({}))) as { path?: string };
4208
- const { inspectDataset } = await import("./train");
4209
- return Response.json(await inspectDataset(body.path ?? ""));
4210
- }
4211
- if (url.pathname === "/api/finetune/submit" && request.method === "POST") {
4212
- const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
4213
- if (!body.model_dir || !body.data_dir)
4214
- return Response.json({ ok: false, error: "model_dir and data_dir required" }, { status: 400 });
4215
- const store = await ensureJobs();
4216
- const { submitSubprocess } = await import("./jobs");
4217
- const { homedir } = await import("node:os");
4218
- const adapterPath = (body.adapter_path as string) ||
4219
- `${homedir()}/.cache/mlx-bun/adapters/adapter-${Date.now()}`;
4220
- const { jobId } = submitSubprocess(store, "finetune",
4221
- { ...body, adapter_path: adapterPath }, adapterPath);
4222
- return Response.json({ ok: true, job_id: jobId, adapter_path: adapterPath });
4223
- }
4224
- if (url.pathname === "/api/finetune/merge" && request.method === "POST") {
4225
- const body = (await request.json().catch(() => ({}))) as {
4226
- adapter_a?: string; adapter_b?: string; scales?: number[];
4227
- };
4228
- if (!body.adapter_a || !body.adapter_b)
4229
- return Response.json({ ok: false, error: "adapter_a and adapter_b required" }, { status: 400 });
4230
- try {
4231
- const { mergeAdapters } = await import("./train");
4232
- const { homedir } = await import("node:os");
4233
- const mergedPath = `${homedir()}/.cache/mlx-bun/adapters/merged-${Date.now()}`;
4234
- const stats = await mergeAdapters([body.adapter_a, body.adapter_b], mergedPath, body.scales);
4235
- return Response.json({ ok: true, merged_path: mergedPath, stats });
4236
- } catch (e) {
4237
- return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
4238
- }
4239
- }
4240
- if (url.pathname === "/api/finetune/export" && request.method === "POST") {
4241
- const body = (await request.json().catch(() => ({}))) as {
4242
- base_model?: string; adapter_path?: string; method?: string;
4243
- };
4244
- if (!body.base_model || !body.adapter_path)
4245
- return Response.json({ ok: false, error: "base_model and adapter_path required" }, { status: 400 });
4246
- try {
4247
- const { exportAdapter } = await import("./train");
4248
- const { homedir } = await import("node:os");
4249
- const exportPath = `${homedir()}/.cache/mlx-bun/exports/export-${Date.now()}`;
4250
- const manifest = await exportAdapter(exportPath, body.base_model, body.adapter_path, body.method);
4251
- return Response.json({ ok: true, export_path: exportPath, manifest });
4252
- } catch (e) {
4253
- return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
4254
- }
4255
- }
4256
-
4257
- // --- HF token settings + push-to-hub (model & dataset repos) ------
4258
- if (url.pathname === "/api/settings/hf-token" && request.method === "GET") {
4259
- const { hasHfToken } = await import("./hf-push");
4260
- return Response.json({ ok: true, hasToken: hasHfToken() });
4261
- }
4262
- if (url.pathname === "/api/settings/hf-token" && request.method === "POST") {
4263
- const body = (await request.json().catch(() => ({}))) as { token?: string };
4264
- if (!body.token) return Response.json({ ok: false, error: "token required" }, { status: 400 });
4265
- const { saveHfToken } = await import("./hf-push");
4266
- saveHfToken(body.token);
4267
- return Response.json({ ok: true });
4268
- }
4269
-
4270
- // --- Durable tool-approvals settings (plan §5.4/§6.5/§9 Phase 2) ---
4271
- // The web chat's own "always allow this tool" list
4272
- // (~/.mlx-bun/tool-approvals.json, src/tool-approvals.ts). The
4273
- // approval card itself grants an always-allow via the `approval` WS
4274
- // frame (alwaysAllow:true) — these REST routes are read/forget only,
4275
- // for the settings panel's list view (matches the hf-token GET/POST
4276
- // pattern's separation of "the card that grants" vs "settings that
4277
- // manage" — there is no POST here on purpose, granting always goes
4278
- // through the approval card, never a bare settings form).
4279
- if (url.pathname === "/api/settings/tool-approvals" && request.method === "GET") {
4280
- const { listAlwaysAllowedTools } = await import("./tool-approvals");
4281
- return Response.json({ ok: true, alwaysAllow: listAlwaysAllowedTools() });
4282
- }
4283
- if (url.pathname === "/api/settings/tool-approvals" && request.method === "DELETE") {
4284
- const body = (await request.json().catch(() => ({}))) as { tool?: string };
4285
- if (!body.tool) return Response.json({ ok: false, error: "tool required" }, { status: 400 });
4286
- const { revokeToolAlwaysAllowed } = await import("./tool-approvals");
4287
- const file = revokeToolAlwaysAllowed(body.tool);
4288
- return Response.json({ ok: true, alwaysAllow: Object.keys(file.allows).sort() });
4289
- }
4290
- {
4291
- const m = url.pathname.match(/^\/api\/(quantize|finetune|dataset)\/push$/);
4292
- if (m && request.method === "POST") {
4293
- const kind = m[1]!;
4294
- const body = (await request.json().catch(() => ({}))) as {
4295
- job_id?: string; repo_id?: string; private?: boolean; source_path?: string;
4296
- };
4297
- if (!body.repo_id) return Response.json({ ok: false, error: "repo_id required" }, { status: 400 });
4298
- const { getHfToken, uploadFolder } = await import("./hf-push");
4299
- const token = getHfToken();
4300
- if (!token)
4301
- return Response.json({ ok: false, error: "no HF token saved — add one in Settings → Hugging Face" }, { status: 400 });
4302
- const store = await ensureJobs();
4303
- let dir = body.source_path;
4304
- if (!dir && body.job_id) dir = store.get(body.job_id)?.output_path ?? undefined;
4305
- if (!dir) return Response.json({ ok: false, error: "no source dir (pass job_id or source_path)" }, { status: 400 });
4306
- try {
4307
- const r = await uploadFolder(dir, body.repo_id, {
4308
- repoType: kind === "dataset" ? "dataset" : "model",
4309
- private: !!body.private, token,
4310
- });
4311
- return Response.json({ ok: true, url: r.url });
4312
- } catch (e) {
4313
- return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
4314
- }
4315
- }
4316
- }
4317
-
4318
- // gc plan/execute (web-ui-pass-plan.md #9): thin wrappers over the CLI's
4319
- // own `mlx-bun gc` planner/executor (src/registry.ts planGc/executeGc).
4320
- // Loopback-served admin route — no auth beyond the server's own bind
4321
- // (matches every other /api/* route here). GET plans (read-only, cheap:
4322
- // config.json + safetensors index reads, no tensor bytes); POST executes
4323
- // and requires an explicit {yes:true} body (mirrors the CLI's --yes gate)
4324
- // so a stray call can't delete anything by accident.
4325
- if (url.pathname === "/api/gc/plan" && request.method === "GET") {
4326
- const { planGc } = await import("./registry");
4327
- const plans = planGc().filter(
4328
- (p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
4329
- );
4330
- const superseded = plans.map((p) => ({
4331
- repo_id: p.repoId,
4332
- prune_snapshots: p.pruneSnapshots.length,
4333
- skipped_snapshots: p.skippedSnapshots.length,
4334
- dead_blobs: p.deadBlobs.length,
4335
- reclaim_bytes: p.reclaimBytes,
4336
- }));
4337
- const reclaim_bytes = plans.reduce((a, p) => a + p.reclaimBytes, 0);
4338
- return Response.json({ ok: true, superseded, reclaim_bytes });
4339
- }
4340
- if (url.pathname === "/api/gc/execute" && request.method === "POST") {
4341
- const body = (await request.json().catch(() => ({}))) as { yes?: boolean };
4342
- if (body.yes !== true)
4343
- return Response.json({ ok: false, error: "pass {\"yes\": true} to confirm deletion" }, { status: 400 });
4344
- const { planGc, executeGc, Registry } = await import("./registry");
4345
- const plans = planGc().filter(
4346
- (p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
4347
- );
4348
- const res = executeGc(plans);
4349
- const reg = new Registry();
4350
- try {
4351
- await reg.scan(); // reap deleted snapshots from the registry
4352
- } finally {
4353
- reg.close();
4354
- }
4355
- libraryCache = null; // force /library to reflect the deletion
4356
- return Response.json({
4357
- ok: true,
4358
- snapshots: res.snapshots, blobs: res.blobs, reclaimed_bytes: res.reclaimedBytes,
4359
- });
4360
- }
3665
+ const labResponse = await handleLabRoute(url, request, {
3666
+ ensureJobs,
3667
+ serverPort: () => server.port,
3668
+ invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
3669
+ });
3670
+ if (labResponse) return labResponse;
4361
3671
 
4362
- if (url.pathname === "/api/jobs" && request.method === "GET") {
4363
- const store = await ensureJobs();
4364
- const limit = Number(url.searchParams.get("limit") ?? "50");
4365
- const kind = url.searchParams.get("kind") ?? undefined;
4366
- return Response.json({ ok: true, jobs: store.recent(limit, kind) });
4367
- }
4368
- {
4369
- const m = url.pathname.match(/^\/api\/jobs\/([^/]+?)(\/stream)?$/);
4370
- if (m && request.method === "GET") {
4371
- const store = await ensureJobs();
4372
- if (m[2]) {
4373
- const { streamJobResponse } = await import("./jobs");
4374
- return streamJobResponse(store, m[1]!);
4375
- }
4376
- const job = store.get(m[1]!);
4377
- if (!job) return Response.json({ ok: false, error: "job not found" }, { status: 404 });
4378
- return Response.json({ ok: true, job });
4379
- }
4380
- }
3672
+ const adminResponse = await handleAdminRoute(url, request, {
3673
+ ensureJobs,
3674
+ invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
3675
+ });
3676
+ if (adminResponse) return adminResponse;
4381
3677
 
4382
3678
  return Response.json({ error: { message: "not found" } }, { status: 404 });
4383
3679
  },
4384
3680
  });
3681
+ serverLifecycles.set(serverRef, {
3682
+ flush: flushDurability,
3683
+ stats: durabilityStats,
3684
+ stopTimers: () => {
3685
+ if (demoteTimer) clearInterval(demoteTimer);
3686
+ demoteTimer = null;
3687
+ },
3688
+ });
4385
3689
  return serverRef;
4386
3690
  }