mlx-bun 0.3.0 → 0.5.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 (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
package/src/server.ts CHANGED
@@ -1,4 +1,7 @@
1
+ import { ContinuationPersistence } from "./backends/mlx/continuation-persistence";
1
2
  import { modelServingBinding } from "./backends/mlx/model-serving";
3
+ import { handleAudioRoute } from "./serve/audio-routes";
4
+ import { TranscriptionService, defaultWhisperRecord } from "./serve/transcription-service";
2
5
  import type { ServingContext } from "./serve/model-host";
3
6
  export { generationCheckpointKey } from "./serve/checkpoint-identity";
4
7
  // OpenAI-compatible HTTP server: /v1/chat/completions (+ SSE streaming)
@@ -25,7 +28,7 @@ import {
25
28
  type GenerateOptions,
26
29
  } from "./generate";
27
30
  import { createPreparationExecutor } from "./serve/preparation";
28
- import { cloneKvCaches, legacyCacheCodecs, SpillQueue } from "./kv-store";
31
+ import { cloneKvCaches, legacyCacheCodecs } from "./kv-store";
29
32
  import type { Cache } from "./model/gemma4";
30
33
  import { resolveKvScheme } from "./kv-scheme";
31
34
  import { runtimeValue } from "./runtime-config";
@@ -48,10 +51,11 @@ import { handleLabRoute } from "./serve/lab-routes";
48
51
  import { handleModelAdminRoute } from "./serve/model-admin-routes";
49
52
  import { handleStaticRoute } from "./serve/static-routes";
50
53
 
51
- import { PromptCache, cacheBytes } from "./prompt-cache";
54
+ import { PromptCache } from "./prompt-cache";
55
+ import { CostSizeRetention } from "./storage/retention-policy";
56
+ import { TieredPromptCache } from "./tiered-prompt-cache";
52
57
  import { SsdCacheStore } from "./ssd-cache";
53
58
  import {
54
- SsdDurabilityCoordinator,
55
59
  type DurabilityFlushResult,
56
60
  type DurabilitySnapshotStats,
57
61
  } from "./ssd-durability";
@@ -66,7 +70,7 @@ import {
66
70
  type ResponsesRequest,
67
71
  } from "./responses";
68
72
  import { fit } from "./fit";
69
- import { setMemoryLimit } from "./mlx/ffi";
73
+ import { activeMemory, maxRecommendedWorkingSetSize, setMemoryLimit } from "./mlx/ffi";
70
74
  import { makePiWsHandler, type PiWsData } from "./pi-web";
71
75
  import { ChatStage } from "./serve/chat-stage";
72
76
  import { TextCompletionStage } from "./serve/text-completion-stage";
@@ -76,6 +80,7 @@ import {
76
80
  chatCompletionJson, chatCompletionStream, textCompletionJson, textCompletionStream,
77
81
  } from "./serve/openai-wire";
78
82
  import { createRequestPrep } from "./serve/request-prep";
83
+ import { GeneratedTokenHistory } from "./serve/generated-token-history";
79
84
  import {
80
85
  detectDraftKind,
81
86
  loadContext,
@@ -109,13 +114,15 @@ export interface ServerOptions {
109
114
  * (`--kv-budget`, batching-perf-path P3). Joiners whose projected KV
110
115
  * (prompt + max_tokens, window-capped) would exceed it QUEUE until rows
111
116
  * evict; a request over the budget alone is rejected. Unset = no
112
- * aggregate cap (per-request admission via memoryBudget still applies). */
117
+ * aggregate cap (an explicit memoryBudget also limits each request). */
113
118
  kvBudgetBytes?: number;
114
119
  /** KV quantization override. Unset/"off" is bf16. "config" applies the
115
120
  * model's declared mixed-precision kv_config; supported per-layer schemes
116
121
  * compose with continuous scheduling. A number forces uniform bits
117
122
  * (group size 64, start 0) and uses the preserved serial executor. */
118
123
  kvQuant?: "off" | "config" | number;
124
+ /** Optional absolute row offset for KV conversion; omitted preserves start-zero server policy. */
125
+ quantizedKvStart?: number;
119
126
  /** TurboQuant scheme (docs/design/turboquant.md): a separate axis from
120
127
  * kvQuant above, mutually exclusive with it (`--kv-quant turbo[:k<bits>v
121
128
  * <bits>]` sets this instead of kvQuant). Solo-only in v1 — see
@@ -124,19 +131,13 @@ export interface ServerOptions {
124
131
  /** OPTIONAL paged KV cache (`--paged-kv`, docs/design/kv-cache.md):
125
132
  * vLLM-style block-pool storage for full-attention layers,
126
133
  * gather-to-contiguous before the stock SDPA. Default off (unset = the
127
- * plain KVCache path, byte-identical). v1 scope: serial batch=1,
128
- * Gemma4-family, bf16 — startup refuses `--batch N>1`, `--kv-quant`,
134
+ * plain KVCache path, byte-identical). Gemma4-family, bf16, B1/B>1;
135
+ * startup refuses `--kv-quant`,
129
136
  * turbo, and `--draft-model` combinations; paged requests bypass the
130
137
  * prompt cache and run uncompiled. Gated bit-exact vs the plain path. */
131
138
  pagedKv?: { blockSize?: number };
132
- /** Memory budget for the serving process (admission control — Phase 5).
133
- * Requests whose prompt + max_tokens exceed the budget's max safe
134
- * context are rejected with 400 instead of crashing the GPU: the OOM
135
- * crash class is UNCATCHABLE (Phase 6 — mlx throws from a Metal
136
- * completion handler ⇒ std::terminate; optiq serve died exactly this
137
- * way loading the 26B). Also caps the mlx allocator
138
- * (mlx_set_memory_limit) as defense in depth. Default: machine RAM ×
139
- * WIRED_FRACTION, admission check only, allocator untouched. */
139
+ /** Explicit opt-in process budget. Enforces the fit estimate's context
140
+ * limit and sets the MLX allocator limit. Unset keeps estimates advisory. */
140
141
  memoryBudgetBytes?: number;
141
142
  /** Who owns this server's lifetime: "serve" (persistent, mlx-bun
142
143
  * serve) or "pi-session" (dies with the pi session that started
@@ -166,6 +167,16 @@ export interface ServerOptions {
166
167
  defaultTemperature?: number;
167
168
  defaultTopP?: number;
168
169
  defaultTopK?: number;
170
+ /** Whisper checkpoint directory for /v1/audio/* (`--whisper-model`).
171
+ * Unset ⇒ the first downloaded `whisper` checkpoint is used on demand. */
172
+ whisperModelDir?: string;
173
+ /** Display id for the Whisper checkpoint. */
174
+ whisperModelId?: string;
175
+ /** Seconds idle before the Whisper weights are disposed
176
+ * (`--whisper-idle-unload`, default 300; 0 keeps them resident). */
177
+ whisperIdleUnloadSec?: number;
178
+ /** Keep the Whisper weights resident (`--whisper-resident`). */
179
+ whisperResident?: boolean;
169
180
  /** Completion cap when the request omits max_tokens (`--max-tokens`).
170
181
  * mlx_lm.server's flag; its default there is 512 — ours stays 65,536 so
171
182
  * thinking traces never truncate. `--max-tokens 512` = mlx-lm behavior. */
@@ -287,16 +298,8 @@ export interface ContextAdmissionDecision {
287
298
  clamped: boolean;
288
299
  }
289
300
 
290
- /** Resolve a request's completion upper bound against the admitted context.
291
- *
292
- * `max_tokens` is a ceiling, not a promise that every token will be emitted:
293
- * when a prompt fits but a broad client-wide `max_tokens` would push the
294
- * reservation past the safe context, the bound is CLAMPED to the remaining
295
- * room instead of rejecting an otherwise valid request (the pre-v0.0.13
296
- * behavior 400'd a prompt with thousands of tokens of generation room over a
297
- * 17-token overshoot). This never weakens the OOM guard — generation stops at
298
- * the clamped ceiling, inside the admitted context. Only a prompt that leaves
299
- * no generation slot at all is rejected. */
301
+ /** Resolve a completion cap against an explicitly enforced context limit.
302
+ * Serving keeps fit estimates advisory unless the operator chooses a budget. */
300
303
  export function admitRequestContext(
301
304
  promptTokens: number,
302
305
  requestedMaxTokens: number,
@@ -333,8 +336,7 @@ export function createServer(
333
336
  // The scheduler chooses its B=1 fast path or B=N step from active rows.
334
337
  // Both full-attention (CPM) and
335
338
  // sliding-window (Gemma) models batch — the scheduler assembles each layer's
336
- // cache by attention type. Non-batchable requests (vision / adapters /
337
- // user seed / unsupported explicit kv-quant) drain to the serial executor
339
+ // cache by attention type. Unsupported model/method combinations drain to the serial executor
338
340
  // (see GenerationGateway.place). No inference setting is rewritten.
339
341
  // DEFAULT 8 (flipped 2026-07-05, Josh's call, after GATE-B1-SPEED): a
340
342
  // lone request through the batch lane IS the serial engine (adopted
@@ -352,8 +354,7 @@ export function createServer(
352
354
  throw new Error("--generation-checkpoint expects a positive integer token interval");
353
355
  if (!serverOptions.ssdCacheDir)
354
356
  throw new Error("--generation-checkpoint requires --ssd-cache <dir>");
355
- if (batch !== 1)
356
- throw new Error("--generation-checkpoint requires --batch 1 (in-flight resume is serial-only)");
357
+
357
358
  }
358
359
  const defaultGeneratedTokens =
359
360
  serverOptions.defaultMaxTokens ?? ctx.glmMemoryPlan?.maxGenerationTokens;
@@ -376,32 +377,11 @@ export function createServer(
376
377
  const resolvedKvScheme = resolveKvScheme({
377
378
  override: serverOptions.kvQuant,
378
379
  turboQuant: serverOptions.turboQuant,
380
+ quantizedKvStart: serverOptions.quantizedKvStart,
379
381
  config: ctx.kvConfig,
380
382
  });
381
383
  const kvScheme = resolvedKvScheme.generationOptions;
382
- // Phase 3.1: kvConfig whose layers are all full-attention BATCHES (the
383
- // scheduler applies the mixed scheme per row); uniform kvBits and configs
384
- // touching rotating layers still route those requests serial. The warning
385
- // only fires for the still-serial compositions.
386
- if (batch > 1 && kvScheme.kvBits)
387
- console.warn(
388
- `[batch] --batch ${batch} with uniform --kv-quant ${kvScheme.kvBits}: uniform ` +
389
- `quantized KV is serial-only (it touches rotating layers) — those requests ` +
390
- `won't batch. Use --kv-quant config for batched mixed-precision KV, or omit ` +
391
- `--kv-quant to batch in bf16. (docs/design/unified-engine-frontier-plan.md)`,
392
- );
393
- // TurboQuant is solo-only in v1 (novel cache class, not batchable by
394
- // construction — see GenerationGateway placement's cache-capability gate).
395
- if (batch > 1 && kvScheme.turboQuant)
396
- console.warn(
397
- `[batch] --batch ${batch} with --kv-quant turbo: TurboQuant is serial-only in v1 ` +
398
- `— those requests won't batch. Omit --kv-quant to batch in bf16. ` +
399
- `(docs/design/turboquant.md)`,
400
- );
401
- // Quantized KV (any axis) excludes the spec lane: drafted requests fall to
402
- // the normal serial path WITH the KV scheme applied rather than losing it
403
- // silently (mirrors the affine kvBits/kvConfig exclusion; the spec loop is
404
- // bf16-KV-only in v1).
384
+ // The model execution plan qualifies the method while retaining KV policy.
405
385
  if (ctx.draft && (kvScheme.turboQuant || kvScheme.kvBits || kvScheme.kvConfig?.length))
406
386
  console.warn(
407
387
  `[spec] --draft-model with quantized KV (--kv-quant ${serverOptions.turboQuant ? "turbo" : String(serverOptions.kvQuant)}): ` +
@@ -427,19 +407,12 @@ export function createServer(
427
407
  }
428
408
  // Paged KV v1 (docs/design/kv-cache.md): explicit refusals, not
429
409
  // silent downgrades — the incompatible combos would otherwise degrade
430
- // quietly (batch: paged caches can't merge; kv-quant: the swap would
431
- // drop the scheme, the exact composition bug the kvQuant gate above
432
- // exists to prevent; draft: the spec lane assumes serial-class caches).
410
+ // quietly (kv-quant: the swap would drop the scheme; speculative
411
+ // provider composition with paged storage is not implemented).
433
412
  if (serverOptions.pagedKv) {
434
- if (batch > 1)
413
+ if (kvScheme.kvConfig?.length || kvScheme.turboQuant)
435
414
  throw new Error(
436
- `--paged-kv is serial-only in v1 — add --batch 1 (got --batch ${batch}). ` +
437
- `Batched paging is the follow-up PR (docs/design/kv-cache.md).`,
438
- );
439
- if (kvScheme.kvBits || kvScheme.kvConfig?.length || kvScheme.turboQuant)
440
- throw new Error(
441
- `--paged-kv is bf16-only in v1 — omit --kv-quant (quantized paged ` +
442
- `blocks are a documented follow-up, docs/design/kv-cache.md).`,
415
+ `--paged-kv supports bf16 and uniform affine KV4/KV8; per-layer and TurboQuant pages are not implemented.`,
443
416
  );
444
417
  if (ctx.draft)
445
418
  throw new Error(
@@ -454,11 +427,7 @@ export function createServer(
454
427
  const bs = serverOptions.pagedKv.blockSize;
455
428
  if (bs !== undefined && (!Number.isInteger(bs) || bs <= 0))
456
429
  throw new Error(`--paged-kv-block-size must be a positive integer (got ${bs})`);
457
- if (serverOptions.ssdCacheDir)
458
- console.warn(
459
- "[paged-kv] --ssd-cache has no effect: paged requests bypass the prompt " +
460
- "cache (v1 non-goal), so nothing reaches the SSD tier.",
461
- );
430
+
462
431
  }
463
432
 
464
433
  // SSD cold tier (docs/design/kv-cache.md): prefix KV survives RAM
@@ -493,6 +462,8 @@ export function createServer(
493
462
  tokenizerHash: Bun.hash(tokJson).toString(16),
494
463
  modelId: ctx.modelId,
495
464
  verify: serverOptions.ssdCacheVerify,
465
+ storage: { layout: runtimeValue("MLX_BUN_SSD_LAYOUT") === "blocks" ? "blocks" : "whole",
466
+ segmented: runtimeValue("MLX_BUN_SSD_SEGMENTED") !== "0" },
496
467
  });
497
468
  const recovered = ssdStore.scan();
498
469
  const capacity = Number.isFinite(ssdStore.maxBytes)
@@ -514,6 +485,15 @@ export function createServer(
514
485
  const h = ssdStore!.find(prompt, ns);
515
486
  return h ? { prefixLen: h.prefixLen, handle: h.entry } : null;
516
487
  },
488
+ findExact: (tokens: number[], ns: string) => {
489
+ const hit = ssdStore!.findExact(tokens, ns);
490
+ return hit ? { prefixLen: hit.prefixLen, handle: hit.entry } : null;
491
+ },
492
+ restoreObjectAsync: async (handle: unknown) => {
493
+ const loaded = await ssdStore!.restoreAsync(handle as import("./ssd-cache").SsdIndexEntry,
494
+ { makeCache: () => [] });
495
+ return loaded ? { ...loaded, retain: () => {} } : null;
496
+ },
517
497
  restore: (handle: unknown) => {
518
498
  const loaded = serving.restore(ssdStore!, handle as import("./ssd-cache").SsdIndexEntry);
519
499
  if (!loaded) return null;
@@ -521,219 +501,138 @@ export function createServer(
521
501
  // own their bytes and no mapping outlives loadKvCache — nothing
522
502
  // to pin, nothing to unmap. retain stays in the entry contract
523
503
  // as a no-op so callers' dispose ordering is unchanged.
524
- return { tokens: loaded.tokens, caches: loaded.caches, retain: () => {} };
504
+ return { tokens: loaded.tokens, caches: loaded.caches, attachments: loaded.attachments, retain: () => {} };
525
505
  },
526
- store: (tokens: number[], caches: import("./model/gemma4").Cache[], ns: string) => {
506
+ ...(serving.restoreAsync && runtimeValue("MLX_BUN_SSD_PREFETCH") !== "0" ? {
507
+ restoreAsync: async (handle: unknown) => {
508
+ const loaded = await serving.restoreAsync!(ssdStore!, handle as import("./ssd-cache").SsdIndexEntry);
509
+ return loaded ? { ...loaded, retain: () => {} } : null;
510
+ },
511
+ } : {}),
512
+ store: (tokens: number[], caches: import("./model/gemma4").Cache[], ns: string,
513
+ attachments?: import("./backends/mlx/checkpoint-state").CheckpointAttachment[]) => {
527
514
  if (ssdStore!.hasDurablePrefix(tokens, ns)) return true;
528
- return ssdStore!.store(tokens, caches, ns);
515
+ return ssdStore!.store(tokens, caches, ns, attachments);
529
516
  },
530
517
  }
531
518
  : null;
532
- const promptCache = new PromptCache(
533
- promptCacheCap,
534
- // RAM eviction AND idle demotion spill to the cold tier NON-BLOCKING
535
- // (2026-07-06, same contract as the write-behind snapshot below): the
536
- // cache hands us OWNED zero-copy clones + copied tokens (made under
537
- // the generation lock — microseconds; entries are immutable so the
538
- // clones stay consistent), and the flush goes through the BOUNDED
539
- // SpillQueue -> storeAsync (generation-locked per tensor, see below) ->
540
- // clone disposal on every settle/drop path (that dispose is what
541
- // actually frees the demoted GPU memory; the queue's byte cap keeps
542
- // starved-gate retention bounded — 2026-07-07 review fix). spillQueue
543
- // is declared with the snapshot scheduler below; safe to close over
544
- // here because spills only fire at put()/demoteIdle time, long after
545
- // init.
546
- ssdStore
547
- ? {
548
- spillOwned: (entry) =>
549
- spillQueue!.enqueue({ tokens: entry.tokens, caches: entry.caches, ns: entry.ns }),
550
- }
551
- : null,
552
- coldTier,
553
- cloneState,
554
- );
519
+ const promptCache = ssdStore && coldTier
520
+ ? new TieredPromptCache(promptCacheCap, ssdStore, coldTier, cloneState,
521
+ runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0")
522
+ : new PromptCache(promptCacheCap, null, null, cloneState);
523
+ if (runtimeValue("MLX_BUN_CACHE_RETENTION") === "cost-size") promptCache.retention = new CostSizeRetention();
524
+ const spillQueue = promptCache instanceof TieredPromptCache ? promptCache.spillQueue : null;
525
+ const durability = promptCache instanceof TieredPromptCache ? promptCache.durability : null;
555
526
  // Responses-API store for previous_response_id resumption (Phase 11):
556
527
  // TTL + byte-capped LRU, port of optiq/response_store.py. Pairs with
557
528
  // the prompt cache: a resumed conversation re-renders the same prefix,
558
529
  // so its KV prefill is already cached.
559
530
  const responseStore = new ResponseStore();
560
531
 
561
- // Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
562
- // fit() solves max safe context from weights + KV growth + prefill
563
- // transient. The active kv-quant scheme (uniform kvBits / per-layer
564
- // kvConfig) is billed at its true bytes/element so a quantized cache
565
- // advertises and admits the larger window it actually enables; only
566
- // TurboQuant still bills bf16 (conservative — no projector for its
567
- // layout yet, and it is solo-only in v1).
532
+ // Memory estimates remain available in diagnostics. Only an explicit
533
+ // operator budget or context cap constrains request planning.
568
534
  const memoryAdmission = ctx.glmMemoryPlan ?? fit(
569
535
  ctx.model.config, ctx.model.weightsBytes, 1,
570
536
  undefined, undefined, 0, serverOptions.memoryBudgetBytes,
571
537
  resolvedKvScheme.fitOptions,
572
538
  );
573
- // Isolated benchmark profile cap. It may narrow physical admission, never
574
- // enlarge it. The normal request planner still clamps prompt + output.
575
539
  const profileContext = runtimeValue("MLX_BUN_RD_CONTEXT_LIMIT");
576
- const contextLimit = profileContext === undefined ? null : Number(profileContext);
577
- if (contextLimit !== null && (!Number.isSafeInteger(contextLimit) || contextLimit < 1))
540
+ const profileLimit = profileContext === undefined ? null : Number(profileContext);
541
+ if (profileLimit !== null && (!Number.isSafeInteger(profileLimit) || profileLimit < 1))
578
542
  throw new Error("MLX_BUN_RD_CONTEXT_LIMIT must be a positive integer");
579
- const admission = contextLimit === null ? memoryAdmission : {
580
- ...memoryAdmission, maxSafeContext: Math.min(contextLimit, memoryAdmission.maxSafeContext),
581
- };
582
- // A zero ceiling is a warning, never a startup refusal: killing the
583
- // server here can only parrot the per-request admission message (which
584
- // still fires, with this same ceiling) or be a false positive from the
585
- // fit model itself — it can never save anything the request gate
586
- // doesn't. Serve until physically incapable.
587
- if (admission.maxSafeContext < 1)
588
- console.warn(
589
- `[admission] memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB leaves no ` +
590
- `safe context for ${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB) ` +
591
- `— serving anyway; generation requests will be refused until the budget is raised`,
592
- );
543
+ const budgetLimit = serverOptions.memoryBudgetBytes !== undefined
544
+ ? memoryAdmission.maxSafeContext : ctx.glmMemoryPlan?.contextTokens ?? null;
545
+ const contextLimit = profileLimit === null ? budgetLimit
546
+ : Math.min(profileLimit, budgetLimit ?? Infinity);
547
+ const admission = memoryAdmission;
593
548
  const allocatorLimit =
594
549
  admission.allocatorLimitBytes ?? serverOptions.memoryBudgetBytes;
595
550
  if (allocatorLimit) setMemoryLimit(allocatorLimit);
596
551
 
597
- const memoryBudget = {
598
- usableBytes: admission.usableBytes,
599
- kvOptions: resolvedKvScheme.fitOptions,
600
- promptCache,
601
- };
602
- const runGeneration = serving.createSerial({
552
+ let checkpointPersistence: ContinuationPersistence | undefined;
553
+ const executionServices: import("./backends/mlx/serial-executor").MlxSerialServices = {
554
+ get checkpointPersistence() { return checkpointPersistence; },
603
555
  promptCache, checkpoints: ssdStore,
604
- memoryBudget,
605
556
  checkpointEveryTokens: serverOptions.generationCheckpointTokens,
606
557
  identity: { artifact: ctx.profile.artifact, implementation: ctx.profile.profile.execution,
607
558
  stateAbi: "legacy-cache-array-v1", codecs: stateCodecs.id },
608
559
  adapterNamespace: (adapters) => ctx.adapters.cacheNamespace(adapters),
609
560
  cloneState,
610
- });
561
+ };
562
+ serving.gateway.configureContinuation?.(executionServices);
563
+ const runGeneration = serving.createSerial(executionServices);
611
564
 
612
- // Write-behind persistence (restart survival — the oMLX boundary-snapshot
613
- // idea at whole-entry granularity, spill-on-evict alone can't survive a
614
- // clean exit): after a request completes, snapshot its still-RAM-resident
615
- // entry to SSD off the request path. Debounced + coalesced per namespace —
616
- // an agent hammering one conversation persists the settled state once
617
- // things go quiet, not every turn. Runs under the gateway lock (byte
618
- // extraction must not race a generation mutating the entry); if the entry
619
- // was evicted (already spilled) or extended (a newer schedule pending)
620
- // meanwhile, findExact misses and nothing is written.
621
- // Keyed by namespace and exact tokens: a sub-second final [prompt+gen] put
622
- // must not cancel the pending boundary snapshot, and unrelated prompts of
623
- // equal length must not cancel each other. Exact reschedules still
624
- // coalesce; stale keys self-clean when findExact misses.
625
- //
626
- // NON-BLOCKING (2026-07-06, the write-behind persistence contract): the
627
- // gateway lock is held only for a zero-copy SNAPSHOT (findExact +
628
- // cloneKvCaches — microseconds; entries are immutable so the clones are
629
- // consistent forever). The flush itself runs OFF the lock via
630
- // storeAsync, and writes chain serially so two multi-hundred-MB flushes
631
- // never overlap.
632
- //
633
- // IDLE-GATED (2026-07-07, the decode@ctx fix): "off the lock" was not
634
- // enough — every per-tensor flush step is a blocking GPU sync on the
635
- // SAME stream decode uses (ops.contiguous enqueues a kernel, rawBytesView
636
- // evals) plus a synchronous multi-MB writeSync, and the setImmediate
637
- // pacing interleaved those slices exactly between decode tokens. A ~16k
638
- // entry's flush overlapping the bench's ctx repeats depressed decode@ctx
639
- // ~9% on e4b (mlx-lm runs no equivalent background work). Now every step
640
- // — including the first — runs through gateway.runExclusive(), so the
641
- // flush only progresses while NOTHING is generating and pauses when a
642
- // request arrives mid-flush. Tradeoffs, accepted: durability waits for a
643
- // quiet moment (single-user serving quiesces constantly; sustained
644
- // hammering defers the flush AND the spill clones' GPU-memory release —
645
- // bounded by the chain). MLX_BUN_SSD_WRITEBEHIND=0
646
- // disables write-behind snapshots entirely — the paired-A/B lever + kill
647
- // switch (restart survival then degrades to spill-on-evict only).
648
- const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
649
- const ssdFlushStep = <T>(step: () => T): Promise<T> =>
650
- gateway.runExclusive(async () => step());
651
- // Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
652
- // kv-store.ts): pending clones pin their entries' GPU buffers while the
653
- // idle gate starves under sustained traffic, so QUEUED bytes are capped —
654
- // over cap the oldest queued spill drops (clone disposed immediately, a
655
- // future cache miss, never a wrong result) instead of accumulating past
656
- // the prompt-cache cap. Default 2 GB (a quarter of the 8 GB RAM-cache
657
- // default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides. The durability
658
- // coordinator retains a dirty record until the queue confirms the atomic
659
- // store. Explicit flush and graceful shutdown can therefore retry a drop.
660
- // `|| 2` would coerce an explicit "0" back to 2 GB — parse so 0 works
661
- // (cap 0 = keep only the newest + in-flight clone pinned; the soft cap
662
- // never drops the item just enqueued).
663
- // The gateway exists before the spill queue because the SSD writer uses
664
- // its idle boundary to avoid competing with generation.
565
+ // Cache policy owns RAM residency and SSD persistence independently of
566
+ // request scheduling. Published state is immutable.
665
567
  const gateway = new GenerationGateway(serving.gateway, batch, runGeneration, {
666
568
  kvBudgetBytes: serverOptions.kvBudgetBytes,
667
- memoryBudget,
668
569
  checkpoints: !!(serverOptions.generationCheckpointTokens && ssdStore),
669
570
  stateCodecs,
670
571
  kvScheme: resolvedKvScheme,
671
572
  promptCache,
672
- beforeSerial: async () => { await flushDurability(); },
573
+ adapterNamespace: (adapters) => ctx.adapters.cacheNamespace(adapters),
673
574
  });
674
575
  const spillQueueGbRaw = Number(runtimeValue("MLX_BUN_SSD_SPILL_QUEUE_GB"));
675
576
  const spillQueueCapBytes =
676
577
  (Number.isFinite(spillQueueGbRaw) && spillQueueGbRaw >= 0 ? spillQueueGbRaw : 2) * 1024 ** 3;
677
- const spillQueue = ssdStore
678
- ? new SpillQueue(
679
- spillQueueCapBytes,
680
- cacheBytes,
681
- (item) => ssdStore!.storeAsync(item.tokens, item.caches, item.ns, ssdFlushStep),
682
- (caches) => { for (const c of caches) c.dispose(); },
683
- )
684
- : null;
685
- const durability = ssdStore && spillQueue && writeBehindOn
686
- ? new SsdDurabilityCoordinator(
687
- gateway,
688
- promptCache,
689
- spillQueue,
690
- cloneState,
691
- (tokens, ns) => ssdStore.hasDurablePrefix(tokens, ns),
692
- )
693
- : null;
694
- const durabilityStats = (): DurabilitySnapshotStats =>
695
- durability?.stats ?? {
578
+ if (ssdStore && serverOptions.generationCheckpointTokens)
579
+ checkpointPersistence = new ContinuationPersistence(ssdStore, {
580
+ maxBytes: spillQueueCapBytes,
581
+ });
582
+ // Keep allocator headroom for the next forward. Only optional cache
583
+ // snapshots are reclaimed; context, batch size and sampling stay intact.
584
+ // Do not count MLX's reusable pool as live state or await an SSD write.
585
+ const cacheWorkingSet = Math.min(maxRecommendedWorkingSetSize(), allocatorLimit ?? Infinity);
586
+ const cacheOverBudget = () => Math.max(activeMemory(),
587
+ ctx.model.weightsBytes + Math.max(promptCache.totalBytes, spillQueue?.pendingBytes ?? 0)) > cacheWorkingSet * 0.85;
588
+ promptCache.pressure = {
589
+ // File-backed weights may not yet appear in MLX's active allocations
590
+ // before the first forward. Account for their known footprint as well.
591
+ overBudget: cacheOverBudget,
592
+ };
593
+ const durabilityStats = (): DurabilitySnapshotStats => {
594
+ const stats = durability?.stats ?? {
696
595
  pendingSnapshots: 0,
697
596
  pendingSpills: spillQueue?.pendingCount ?? 0,
698
597
  pendingSpillBytes: spillQueue?.pendingBytes ?? 0,
699
598
  droppedSpills: spillQueue?.droppedCount ?? 0,
700
599
  failedSpills: spillQueue?.failedCount ?? 0,
701
600
  };
601
+ const checkpoints = checkpointPersistence?.stats;
602
+ return { ...stats, pendingSpills: stats.pendingSpills + (checkpoints?.pendingCount ?? 0),
603
+ pendingSpillBytes: stats.pendingSpillBytes + (checkpoints?.pendingBytes ?? 0),
604
+ droppedSpills: stats.droppedSpills + (checkpoints?.dropped ?? 0),
605
+ failedSpills: stats.failedSpills + (checkpoints?.failed ?? 0) };
606
+ };
702
607
  const flushDurability = async (): Promise<DurabilityFlushResult> => {
703
- if (durability) return durability.flush();
704
608
  const started = performance.now();
705
- await spillQueue?.drain();
609
+ const checkpoints = await checkpointPersistence?.flush();
610
+ const result = durability ? await durability.flush() : null;
611
+ if (!result) await spillQueue?.drain();
706
612
  const stats = durabilityStats();
707
613
  return {
708
614
  ...stats,
709
- durable: stats.pendingSpills === 0,
710
- flushedSnapshots: 0,
711
- missingSnapshots: 0,
615
+ durable: (result?.durable ?? stats.pendingSpills === 0) && (checkpoints?.durable ?? true) && stats.pendingSpills === 0,
616
+ flushedSnapshots: result?.flushedSnapshots ?? 0,
617
+ missingSnapshots: result?.missingSnapshots ?? 0,
712
618
  elapsedMs: performance.now() - started,
713
619
  };
714
620
  };
715
- // Every put() — serial lane AND batch scheduler — schedules the snapshot
716
- // (Layer 0: batch-lane entries survive restarts too, not just evictions).
717
- promptCache.onPut = durability ? (tokens, ns) => durability.schedule(tokens, ns) : null;
621
+ // Remember exact generated IDs independently of cache persistence.
622
+ const tokenHistory = new GeneratedTokenHistory(ctx.tokenizer);
623
+ if (ssdStore) for (const tokens of ssdStore.tokenPrefixes()) tokenHistory.remember(tokens);
624
+ promptCache.onPut = (tokens) => {
625
+ tokenHistory.remember(tokens);
626
+ };
718
627
 
719
- // Idle demotion (Layer 0): entries unused for --ssd-demote-idle seconds
720
- // spill to SSD and free their GPU memory — the RAM tier drains between
721
- // bursts while every prefix stays reachable (take() restores via
722
- // zero-copy mmap). The exclusive section below covers only the sweep's
723
- // zero-copy clone + dispose (spillOwned above); the SSD write itself
724
- // runs off-lock on ssdWriteChain. Swept only when the engine is TRULY idle: the
725
- // runExclusive below registers as a serial waiter, which would DRAIN a
726
- // running batch, so the activity check guards it (a momentary exclusive
727
- // grab of an idle engine is free). 0 disables.
628
+ // Cache age, not scheduler activity, decides idle demotion. The cache
629
+ // queues any missing SSD copy and retains RAM until that copy commits.
728
630
  const demoteIdleMs = (serverOptions.ssdDemoteIdleSec ?? (ssdStore ? 300 : 0)) * 1000;
729
631
  let demoteTimer: ReturnType<typeof setInterval> | null = null;
730
632
  if (ssdStore && demoteIdleMs > 0) {
731
633
  demoteTimer = setInterval(() => {
732
- if (gateway.busy) return;
733
- void gateway.runExclusive(async () => {
734
- const n = promptCache.demoteIdle(demoteIdleMs);
735
- if (n > 0) console.log(`[ssd-cache] demoted ${n} idle entr${n === 1 ? "y" : "ies"} to disk`);
736
- }).catch(() => {});
634
+ const n = promptCache.demoteIdle(demoteIdleMs);
635
+ if (n > 0) console.log(`[ssd-cache] demoted ${n} idle entr${n === 1 ? "y" : "ies"} to disk`);
737
636
  }, Math.max(30_000, demoteIdleMs / 4));
738
637
  demoteTimer.unref?.();
739
638
  }
@@ -743,7 +642,33 @@ export function createServer(
743
642
 
744
643
  // /library response cache (30 s) — registry + config reads only.
745
644
  const startedAt = Date.now();
746
- const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt);
645
+ // Speech-to-text companion (docs/reference/server-api.md "Audio
646
+ // transcription"): a Whisper checkpoint served beside the chat model,
647
+ // loaded on the first /v1/audio request and paged out after
648
+ // --whisper-idle-unload seconds. Explicit --whisper-model wins; otherwise
649
+ // the first downloaded `whisper` checkpoint is resolved lazily.
650
+ let transcription: TranscriptionService | null = null;
651
+ let transcriptionResolved = false;
652
+ const transcriptionService = async (): Promise<TranscriptionService | null> => {
653
+ if (transcription || transcriptionResolved) return transcription;
654
+ transcriptionResolved = true;
655
+ const rec = serverOptions.whisperModelDir
656
+ ? { path: serverOptions.whisperModelDir, repoId: serverOptions.whisperModelId ?? serverOptions.whisperModelDir }
657
+ : await defaultWhisperRecord();
658
+ if (!rec) return null;
659
+ transcription = new TranscriptionService({
660
+ modelDir: rec.path, modelId: rec.repoId,
661
+ idleUnloadSec: serverOptions.whisperIdleUnloadSec,
662
+ resident: serverOptions.whisperResident,
663
+ exclusive: (fn, signal) => gateway.runExclusive(fn, undefined, signal),
664
+ });
665
+ return transcription;
666
+ };
667
+ const transcriptionInfo = async () => {
668
+ const svc = await transcriptionService();
669
+ return svc ? { id: svc.modelId, resident: svc.resident } : null;
670
+ };
671
+ const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt, transcriptionInfo);
747
672
 
748
673
  // Captured so the WebSocket handler can resolve the bound (possibly
749
674
  // ephemeral) port lazily for the loopback pi provider.
@@ -773,16 +698,26 @@ export function createServer(
773
698
  // → inferenceStage.admit → inferenceStage.run → InferenceResult → wire
774
699
  // (/v1/completions substitutes textStage; Anthropic and Responses reuse
775
700
  // chatStage with their own wire formats.)
776
- const prep = createRequestPrep({ ctx, serverOptions, kvScheme, defaultGeneratedTokens });
701
+ const prep = createRequestPrep({ ctx, serverOptions, kvScheme, defaultGeneratedTokens, tokenHistory });
777
702
  const { templateOptionsFor } = prep;
778
- const preparation = createPreparationExecutor((work, signal) => gateway.runExclusive(work, undefined, signal), batch);
703
+ const preparation = createPreparationExecutor((work, signal) => gateway.runPreparation(work, signal),
704
+ batch, gateway.mediaBatchingEnabled ? batch : 1);
779
705
  const chatStage = new ChatStage(
780
- ctx, prep, promptCache, admission.maxSafeContext, serverOptions.defaultAdapter,
706
+ ctx, prep, promptCache, contextLimit, serverOptions.defaultAdapter,
781
707
  preparation, serving.buildPrompt);
782
708
  const textStage = new TextCompletionStage(
783
- ctx, prep, admission.maxSafeContext, defaultGeneratedTokens, serverOptions.defaultAdapter);
709
+ ctx, prep, contextLimit, defaultGeneratedTokens, serverOptions.defaultAdapter);
784
710
  const inferenceStage = new InferenceStage(completionExecutor);
785
711
  const openAiMeta = (id: string) => ({ id, created: Math.floor(Date.now() / 1000), model: ctx.modelId });
712
+ /** Carry application session affinity into the shared cache request fields. */
713
+ const applyCacheSession = (body: ChatRequestParams, request: Request, original: unknown = body) => {
714
+ const fields = original as { session_id?: unknown; prompt_cache_key?: unknown };
715
+ const session = typeof fields.session_id === "string" ? fields.session_id :
716
+ typeof fields.prompt_cache_key === "string" ? fields.prompt_cache_key :
717
+ request.headers.get("x-session-affinity") ?? request.headers.get("session_id") ?? undefined;
718
+ if (runtimeValue("MLX_BUN_SESSION_CACHE") !== "0") body.session_id = session;
719
+ else { delete body.session_id; delete body.prompt_cache_key; }
720
+ };
786
721
  /** Parse the JSON body under the request's trace; a bad body is a 400. */
787
722
  const parseBody = async <T,>(request: Request, trace: PromptResponseTrace | undefined): Promise<T | Response> => {
788
723
  const closeBodyParse = trace?.begin("request.body_parse");
@@ -821,6 +756,7 @@ export function createServer(
821
756
  // capability flags — true if a tower is loaded or loadable (lazy)
822
757
  vision: !!(ctx.vision || ctx.loadVision),
823
758
  audio: !!(ctx.audio || ctx.loadAudio),
759
+ transcription: async () => (await transcriptionService()) !== null,
824
760
  thinking: ctx.template.supportsThinking,
825
761
  // Model-author sampling defaults (generation_config.json, server-CLI
826
762
  // overrides applied) for the sampling popover's per-model recommended
@@ -837,6 +773,12 @@ export function createServer(
837
773
  async fetch(request, server) {
838
774
  const url = new URL(request.url);
839
775
 
776
+ if (url.pathname === "/admin/cache/session/close" && request.method === "POST") {
777
+ const body = await parseBody<{ session_id?: string }>(request, undefined);
778
+ if (body instanceof Response) return body;
779
+ if (typeof body.session_id === "string") promptCache.closeSession(body.session_id);
780
+ return Response.json({ closed: typeof body.session_id === "string" });
781
+ }
840
782
  if (url.pathname === "/admin/cache/flush" && request.method === "POST") {
841
783
  const result = await flushDurability();
842
784
  return Response.json(
@@ -868,6 +810,9 @@ export function createServer(
868
810
  const modelAdminResponse = await handleModelAdminRoute(url, request, ctx, gateway);
869
811
  if (modelAdminResponse) return modelAdminResponse;
870
812
 
813
+ const audioResponse = await handleAudioRoute(url, request, { service: transcriptionService });
814
+ if (audioResponse) return audioResponse;
815
+
871
816
  if (url.pathname === "/fit" && request.method === "GET") {
872
817
  // Fit assessment for the status page: this-machine report at the
873
818
  // admission ceiling + the Apple SKU matrix at a fixed 32k.
@@ -978,30 +923,6 @@ export function createServer(
978
923
  }
979
924
 
980
925
  if (url.pathname === "/stats" && request.method === "GET") {
981
- // Active KV scheme across ALL layers. Since Phase 9 rotating
982
- // (sliding-window) caches quantize too, so every layer the
983
- // scheme names counts — the old display filtered to
984
- // full_attention and silently undercounted (e.g. 26B showed
985
- // 5/30 quantized when its kv_config.json covers all 30).
986
- const layerTypes = ctx.model.config.text.layerTypes;
987
- const kvLayers: Record<string, number> = {};
988
- let kvMode = "bf16";
989
- if (kvScheme.turboQuant) {
990
- // v1: full-attention layers only (sliding-window stays bf16 —
991
- // docs/design/turboquant.md non-goal).
992
- const fullAttn = layerTypes.filter((l) => l !== "sliding_attention").length;
993
- kvMode = `turbo k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`;
994
- kvLayers[`turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`] = fullAttn;
995
- } else if (kvScheme.kvBits) {
996
- kvMode = `uniform-kv${kvScheme.kvBits}`;
997
- kvLayers[`kv${kvScheme.kvBits}`] = layerTypes.length;
998
- } else if (kvScheme.kvConfig) {
999
- kvMode = "mixed (kv_config.json)";
1000
- for (const e of kvScheme.kvConfig)
1001
- kvLayers[`kv${e.bits}`] = (kvLayers[`kv${e.bits}`] ?? 0) + 1;
1002
- }
1003
- const bf16Layers = layerTypes.length - Object.values(kvLayers).reduce((a, b) => a + b, 0);
1004
- const slidingLayers = layerTypes.filter((l) => l === "sliding_attention").length;
1005
926
  return Response.json({
1006
927
  server: {
1007
928
  owner: serverOptions.owner ?? "embedded",
@@ -1014,6 +935,12 @@ export function createServer(
1014
935
  max_bytes: promptCache.maxBytes,
1015
936
  hits: promptCache.hits,
1016
937
  misses: promptCache.misses,
938
+ session_hits: promptCache.sessionHits,
939
+ session_misses: promptCache.sessionMisses,
940
+ prefix_scans: promptCache.prefixScans,
941
+ object_hits: promptCache.objectHits,
942
+ object_misses: promptCache.objectMisses,
943
+ object_restores: promptCache.objectRestores,
1017
944
  },
1018
945
  ...(ssdStore ? {
1019
946
  ssd_cache: {
@@ -1039,16 +966,10 @@ export function createServer(
1039
966
  max_bytes: responseStore.maxBytes,
1040
967
  ttl_ms: responseStore.ttlMs,
1041
968
  },
1042
- kv_quant: {
1043
- mode: kvMode,
1044
- layers: { ...kvLayers, ...(bf16Layers > 0 ? { bf16: bf16Layers } : {}) },
1045
- attention: {
1046
- global: layerTypes.length - slidingLayers,
1047
- sliding_window: slidingLayers,
1048
- },
1049
- },
969
+ kv_quant: resolvedKvScheme.describe(ctx.model.config),
1050
970
  admission: {
1051
971
  max_safe_context: admission.maxSafeContext,
972
+ enforced_context_tokens: contextLimit,
1052
973
  memory_budget_bytes:
1053
974
  ctx.glmMemoryPlan?.processLimitBytes ??
1054
975
  serverOptions.memoryBudgetBytes ?? null,
@@ -1074,7 +995,6 @@ export function createServer(
1074
995
  // A parent-managed GPU job holds this response open. The connection owns
1075
996
  // the native lease, so parent death cannot strand the worker lock.
1076
997
  if (serverOptions.unixSocket && url.pathname === "/admin/lease" && request.method === "POST") {
1077
- await flushDurability();
1078
998
  const lease = await gateway.acquireExecutionLease(request.signal);
1079
999
  let released = false;
1080
1000
  const release = () => {
@@ -1190,6 +1110,7 @@ export function createServer(
1190
1110
  });
1191
1111
  const body = await parseBody<ChatRequestParams>(request, trace);
1192
1112
  if (body instanceof Response) return body;
1113
+ applyCacheSession(body as ChatRequestParams, request);
1193
1114
  const meta = openAiMeta(id);
1194
1115
  const a = await admit(
1195
1116
  inferenceStage, () => chatStage.run(new ChatRequest(body), id, request.signal), trace, "chat request");
@@ -1210,6 +1131,7 @@ export function createServer(
1210
1131
  });
1211
1132
  const body = await parseBody<TextCompletionParams>(request, trace);
1212
1133
  if (body instanceof Response) return body;
1134
+ applyCacheSession(body as ChatRequestParams, request);
1213
1135
  const meta = openAiMeta(id);
1214
1136
  const a = await admit(
1215
1137
  inferenceStage, () => textStage.run(new TextCompletionRequest(body), id), trace, "text completion");
@@ -1239,6 +1161,7 @@ export function createServer(
1239
1161
  let chatBody: ChatRequestParams;
1240
1162
  try {
1241
1163
  chatBody = anthropicToChatBody(anthropicBody) as unknown as ChatRequestParams;
1164
+ applyCacheSession(chatBody, request, anthropicBody);
1242
1165
  } catch (e) {
1243
1166
  return anthropicError(400, (e as Error).message, {});
1244
1167
  }
@@ -1294,6 +1217,7 @@ export function createServer(
1294
1217
  let chatBody: ChatRequestParams;
1295
1218
  try {
1296
1219
  chatBody = responsesToChatBody(responsesBody) as unknown as ChatRequestParams;
1220
+ applyCacheSession(chatBody, request, responsesBody);
1297
1221
  } catch (e) {
1298
1222
  return responsesError(400, (e as Error).message, {});
1299
1223
  }