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
@@ -0,0 +1,57 @@
1
+ /** Token provenance for rendered multi-turn prompts. A generated BPE token
2
+ * sequence need not equal encode(decode(ids)). Preserve the sequence that
3
+ * produced the saved inference state when its rendered text is unchanged.
4
+ * This stores only text and IDs; KV ownership and persistence live elsewhere. */
5
+ export interface PromptTokenHistory {
6
+ remember(tokens: readonly number[]): void;
7
+ resolve(rendered: string, canonical: number[]): number[];
8
+ }
9
+
10
+ interface TokenCodec {
11
+ encode(text: string, addSpecialTokens?: boolean): number[];
12
+ decode(ids: number[], skipSpecialTokens?: boolean): string;
13
+ }
14
+
15
+ export class GeneratedTokenHistory implements PromptTokenHistory {
16
+ readonly #entries = new Map<string, number[]>();
17
+ #chars = 0;
18
+ constructor(readonly codec: TokenCodec, readonly maxChars = 2 ** 21, readonly maxEntries = 16) {}
19
+
20
+ remember(tokens: readonly number[]): void {
21
+ if (!tokens.length) return;
22
+ // Repeated requests can publish the same generated checkpoint. Its text
23
+ // and IDs are already owned here; refresh recency without decoding the
24
+ // entire conversation again on the request's completion path.
25
+ for (const [text, ids] of this.#entries) {
26
+ if (ids.length !== tokens.length || !ids.every((id, i) => id === tokens[i])) continue;
27
+ this.#entries.delete(text);
28
+ this.#entries.set(text, ids);
29
+ return;
30
+ }
31
+ const ids = [...tokens], text = this.codec.decode(ids, false);
32
+ // An unfinished UTF-8 token is not a text boundary. A later checkpoint
33
+ // can supply a complete prefix instead.
34
+ if (!text.length || text.endsWith("\uFFFD") || text.length > this.maxChars) return;
35
+ if (this.#entries.delete(text)) this.#chars -= text.length;
36
+ this.#entries.set(text, ids); this.#chars += text.length;
37
+ while (this.#entries.size > this.maxEntries || this.#chars > this.maxChars) {
38
+ const oldest = this.#entries.keys().next().value!;
39
+ this.#entries.delete(oldest); this.#chars -= oldest.length;
40
+ }
41
+ }
42
+
43
+ resolve(rendered: string, canonical: number[]): number[] {
44
+ let best: { text: string; ids: number[] } | undefined;
45
+ for (const [text, ids] of this.#entries) {
46
+ if (text.length >= rendered.length || (best && text.length <= best.text.length)) continue;
47
+ if (rendered.startsWith(text)) best = { text, ids };
48
+ }
49
+ if (!best) return canonical;
50
+ // Normal canonical output keeps the existing tokenizer fast path.
51
+ if (best.ids.every((id, i) => canonical[i] === id)) return canonical;
52
+ const ids = [...best.ids, ...this.codec.encode(rendered.slice(best.text.length), false)];
53
+ // Decoder cleanup/normalization (e.g. SentencePiece's leading-space
54
+ // rules) can make a standalone suffix unsafe to append.
55
+ return this.codec.decode(ids, false) === rendered ? ids : canonical;
56
+ }
57
+ }
@@ -29,7 +29,7 @@ import { cleanupFailure, disposeResources } from "../engine/resources";
29
29
  // server.py's all-caches-have-merge check). GLM's capability owns compressed
30
30
  // MLA/DSA merge/extract/filter and byte projection; Qwen3.5's SSM cache owns
31
31
  // its row state. The remaining request-shape exclusions include vision, LoRA,
32
- // serial-only KV modes, and user-fixed seed
32
+ // serial-only KV modes
33
33
  // (reproducibility ⇒ solo, matching mlx-lm's _is_batchable). Temperature /
34
34
  // top-p / top-k DO batch (each row samples with its own seed). Full-attention
35
35
  // AND sliding-window (Gemma) models both batch — the scheduler assembles each
@@ -42,9 +42,9 @@ import { MlxArray } from "../mlx/array";
42
42
  import type { RuntimeModel } from "../model/factory";
43
43
  import type { GenerateOptions, GenerateStats, TokenLogprobs } from "../generate";
44
44
  import type { KvScheme } from "../kv-scheme";
45
+ import { createRowSampling } from "../backends/mlx/row-sampling";
45
46
  import { makeStepSampler } from "../sampler";
46
47
  import type { RowPromptCache } from "../backends/mlx/batch-group";
47
- import type { MlxMemoryBudget } from "../backends/mlx/memory-guard";
48
48
  import { bindMlxGateway, type MlxGatewayBinding, type MlxBatchGroup } from "../backends/mlx/gateway-binding";
49
49
  import type { RuntimeConfig } from "../runtime-config";
50
50
  import type { ExecutionRequirements, ResolvedExecution } from "../contracts/execution";
@@ -94,11 +94,12 @@ class AsyncMutex {
94
94
  }
95
95
  }
96
96
 
97
- /** Embeddings-prefill payload for media prompts (vision and/or audio — the
98
- * name predates audio). Any request carrying one routes to the serial lane
99
- * (shape.hasVision) and prefills through generate()'s promptEmbeddings
100
- * path, bypassing the prompt cache. */
97
+ /** Prepared embeddings for vision/audio prompts. Qualified models consume
98
+ * this through shared preparation and decode; other bindings retain serial
99
+ * execution. Token-only prefix caching remains disabled for media. */
101
100
  export type Vision = {
101
+ /** Producer identity includes all media and preceding rendered tokens. */
102
+ prefixIdentity?: string;
102
103
  embeddings: MlxArray;
103
104
  /** bool [L] image-token mask for the bidirectional attention overlay.
104
105
  * Absent when the prompt carries ANY audio — audio(-containing) prompts
@@ -109,12 +110,12 @@ export type Vision = {
109
110
  * where zeroing falls back to imageMask. */
110
111
  multimodalMask?: MlxArray;
111
112
  /** Qwen3.5/3.8 vision: the request's mRoPE positions + decode delta,
112
- * installed on the model for exactly this serial run (server-side). */
113
+ * owned by shared forward input, or scoped to the explicit serial run. */
113
114
  mrope?: import("../model/qwen3-mrope").MropeRequestState;
114
115
  };
115
116
 
116
117
  /** Per-token sink: returning `false` halts this generation (stop sequence).
117
- * `logprobs` is only populated on the serial lane when the request asked for
118
+ * `logprobs` is populated on either lane when the request asked for
118
119
  * logprobs capture (GenerateOptions.logprobs / topLogprobs). */
119
120
  export type OnToken = (
120
121
  token: number,
@@ -172,7 +173,6 @@ export class GenerationGateway {
172
173
  private readonly serialRun: SerialRun,
173
174
  private readonly opts: {
174
175
  kvBudgetBytes?: number;
175
- memoryBudget?: MlxMemoryBudget;
176
176
  checkpoints?: boolean;
177
177
  stateCodecs?: import("../kv-store").CacheCodecProvider;
178
178
  /** The server-wide KV scheme (server.ts kvScheme) — threaded to the
@@ -185,9 +185,7 @@ export class GenerationGateway {
185
185
  * back on finish. Safe to share with the serial lane — both use it
186
186
  * only inside this gateway's mutual-exclusion domain. */
187
187
  promptCache?: RowPromptCache;
188
- /** Finish cache writes before taking the generation lock. Writers
189
- * need that same lock, so this hook must never run inside it. */
190
- beforeSerial?: () => Promise<void>;
188
+ adapterNamespace?: (adapters: string[]) => string;
191
189
  } = {},
192
190
  ) {
193
191
  this.#binding = "createBatchGroup" in model ? model : bindMlxGateway(model);
@@ -235,16 +233,20 @@ export class GenerationGateway {
235
233
  await this.onIdle();
236
234
  }
237
235
 
238
- /** Resolves once the engine is idle (poll-based, ~`pollMs` granularity —
239
- * cheap vs the multi-hundred-ms flushes it paces; resolves without a
240
- * timer when already idle). The SSD write-behind gates each per-tensor
241
- * flush step on this so durability work never steals blocking
242
- * GPU-sync + writeSync slices from an active decode (the 2026-07-07
243
- * decode@ctx contamination — see server.ts's write-behind block). */
236
+ /** Observe idleness. Call runWhenIdle for background work that must also
237
+ * acquire ownership atomically with the idle check. */
244
238
  async onIdle(pollMs = 20): Promise<void> {
245
239
  while (this.busy) await new Promise<void>((r) => setTimeout(r, pollMs));
246
240
  }
247
241
 
242
+ /** Background work waits without requesting a batch drain. The final busy
243
+ * check and mutex acquisition have no await between them, so a foreground
244
+ * request cannot enter between the check and ownership registration. */
245
+ async runWhenIdle<T>(fn: () => Promise<T>, pollMs = 20): Promise<T> {
246
+ while (this.busy) await new Promise<void>(resolve => setTimeout(resolve, pollMs));
247
+ return this.runExclusive(fn);
248
+ }
249
+
248
250
  /** Cumulative rows routed to the batch lane since server start. The serial
249
251
  * lane never advances this — /stats' race-free lane-routing observable
250
252
  * (active/pending are instantaneous and read 0 once a request finishes). */
@@ -272,7 +274,7 @@ export class GenerationGateway {
272
274
  const frozenShape = Object.freeze(shape);
273
275
  const execution = this.#binding.plan(frozenShape, options, {
274
276
  continuous: this.batchingEnabled,
275
- quantizedBatch: shape.kvQuant && this.#kvBatchable(),
277
+ quantizedBatch: (shape.kvQuant || shape.turboQuant) && this.#kvBatchable(),
276
278
  checkpoints: this.opts.checkpoints === true,
277
279
  });
278
280
  return Object.freeze({ shape: frozenShape, mechanism: execution.mechanism, execution });
@@ -317,6 +319,19 @@ export class GenerationGateway {
317
319
  try { return await fn(); } finally { lease.dispose(); }
318
320
  }
319
321
 
322
+ /** Preparation reads model weights without replacing active model state.
323
+ * A compatible backend can perform it between existing decode iterations. */
324
+ runPreparation<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
325
+ if (this.#closed) return Promise.reject(new Error("generation gateway is closed"));
326
+ if (signal?.aborted) return Promise.reject(signal.reason);
327
+ const group = this.batchingEnabled ? this.#ensureScheduler() : undefined;
328
+ return group?.runPreparation
329
+ ? group.runPreparation(work, signal)
330
+ : this.runExclusive(work, undefined, signal);
331
+ }
332
+
333
+ get mediaBatchingEnabled(): boolean { return this.batchingEnabled && !!this.#binding.mediaInput; }
334
+
320
335
  /** Run one generation on the appropriate lane. onToken is invoked per emitted
321
336
  * token (its `false` halts); resolves with stats when the generation ends. */
322
337
  async run(
@@ -330,10 +345,32 @@ export class GenerationGateway {
330
345
  trace?: PromptResponseTrace,
331
346
  ): Promise<GenerateStats> {
332
347
  let reservation;
348
+ const closeRequestWait = trace?.begin("engine.request_wait", { capacity: this.#batch });
333
349
  try { reservation = await acquireReservation(this.#requests, signal); }
334
350
  catch (error) { cleanupFailure(error, () => disposeUnstartedRequest(options, vision)); }
335
- try { return await this.#run(promptIds, options, onToken, vision, shape, placement, signal, trace); }
336
- finally { reservation.dispose(); }
351
+ finally { closeRequestWait?.(); }
352
+ let releasePrefix: (() => void) | undefined;
353
+ try {
354
+ try {
355
+ const adapters = options.adapters?.length
356
+ ? this.opts.adapterNamespace?.(options.adapters) ?? JSON.stringify(options.adapters) : "";
357
+ const baseNamespace = this.#binding.prefixNamespace?.(placement.execution, options, adapters) ??
358
+ (placement.execution?.method === "speculative" ? null : adapters);
359
+ const namespace = baseNamespace !== null && vision?.prefixIdentity
360
+ ? JSON.stringify(["prepared-prefix-v1", vision.prefixIdentity, baseNamespace]) : baseNamespace;
361
+ if ((!vision || placement.execution?.promptCache) && namespace !== null) {
362
+ const closePrefetch = this.opts.promptCache?.prefetch ? trace?.begin("cache.prefetch") : undefined;
363
+ try { releasePrefix = await this.opts.promptCache?.prefetch?.(promptIds, namespace, options.cacheSessionId); }
364
+ finally { closePrefetch?.(); }
365
+ }
366
+ } catch (error) { cleanupFailure(error, () => disposeUnstartedRequest(options,
367
+ placement.mechanism === "continuous" ? undefined : vision)); }
368
+ return await this.#run(promptIds, options, onToken, vision, shape, placement, signal, trace);
369
+ } finally {
370
+ disposeResources([{ dispose: () => releasePrefix?.() }, reservation,
371
+ ...(placement.mechanism === "continuous" && vision
372
+ ? [vision.embeddings, vision.imageMask, vision.multimodalMask].filter(value => value != null) : [])]);
373
+ }
337
374
  }
338
375
 
339
376
  async #run(
@@ -346,7 +383,8 @@ export class GenerationGateway {
346
383
  signal?: AbortSignal,
347
384
  trace?: PromptResponseTrace,
348
385
  ): Promise<GenerateStats> {
349
- const disposeUnstarted = () => disposeUnstartedRequest(options, vision);
386
+ const disposeUnstarted = () => disposeUnstartedRequest(options,
387
+ placement.mechanism === "continuous" ? undefined : vision);
350
388
  if (placement.shape !== shape) {
351
389
  disposeUnstarted();
352
390
  throw new Error("generation placement does not belong to this request shape");
@@ -390,7 +428,7 @@ export class GenerationGateway {
390
428
  });
391
429
  };
392
430
  let started = false;
393
- return Promise.resolve().then(() => this.opts.beforeSerial?.()).then(() => this.runExclusive(async () => {
431
+ return this.runExclusive(async () => {
394
432
  signal?.throwIfAborted();
395
433
  started = true;
396
434
  const stats = await this.serialRun(
@@ -398,7 +436,7 @@ export class GenerationGateway {
398
436
  );
399
437
  signal?.throwIfAborted();
400
438
  return stats;
401
- }, trace, signal))
439
+ }, trace, signal)
402
440
  // Covers a serial waiter aborted before serialRun takes ownership.
403
441
  // generate() also disposes defensively; the operation is idempotent.
404
442
  .finally(() => {
@@ -407,16 +445,25 @@ export class GenerationGateway {
407
445
  });
408
446
  }
409
447
 
448
+ const adapters = options.adapters?.length ? [...options.adapters] : undefined;
449
+ const adapterKey = adapters ? JSON.stringify(adapters) : "";
450
+ const adapterNamespace = () => adapters ? this.opts.adapterNamespace?.(adapters) ?? adapterKey : "";
451
+ const cacheNamespace = vision?.prefixIdentity
452
+ ? () => JSON.stringify(["prepared-prefix-v1", vision.prefixIdentity, adapterNamespace()])
453
+ : adapters ? adapterNamespace : "";
454
+ const context = adapters
455
+ ? this.#binding.bindAdapterContext?.(adapters, `adapters:${adapterKey}`) : undefined;
456
+
410
457
  // The scheduler owns grammar ready/accept sequencing. StepSampler owns the
411
458
  // shared processors -> mask -> logprobs -> sample -> history contract.
412
- const stepSampler = makeStepSampler(options, {
413
- tokenRepresentation: "device",
414
- grammarWait: "external",
415
- historyUpdate: "after-sample",
416
- initialHistory: promptIds,
417
- });
418
- const sample = (logits1V: MlxArray, step: number): MlxArray =>
419
- stepSampler.sample(logits1V, step).token;
459
+ const method = this.#binding.methodRequest?.(placement.execution, options);
460
+ const continuation = method ? undefined : this.#binding.continuationRequest?.(placement.execution, options, promptIds, onToken);
461
+ const sampling = method ? undefined : continuation ?? createRowSampling(makeStepSampler(options, {
462
+ tokenRepresentation: "device", grammarWait: "external",
463
+ historyUpdate: "after-sample", initialHistory: promptIds,
464
+ captureSelectedLogprob: options.logprobs === true,
465
+ captureTopLogprobs: options.topLogprobs,
466
+ }), onToken);
420
467
 
421
468
  let st;
422
469
  this.#rowsSubmitted++;
@@ -425,13 +472,15 @@ export class GenerationGateway {
425
472
  });
426
473
  try {
427
474
  st = await this.#ensureScheduler().submit({
428
- promptIds,
475
+ promptIds, context, cacheNamespace, cacheSessionId: options.cacheSessionId, prefillChunkSize: options.prefillChunkSize,
476
+ ...(vision ? { promptInput: this.#binding.mediaInput!(vision) } : {}),
477
+ continuation: continuation?.continuation,
478
+ statePolicy: this.#binding.statePolicy?.(placement.execution, options, promptIds.length + (options.maxTokens ?? 512)),
429
479
  compiledDecode: placement.execution?.compiledDecode,
430
480
  maxTokens: options.maxTokens ?? 512,
431
481
  eosTokenIds: options.eosTokenIds ?? this.#binding.config.eosTokenIds,
432
- sample,
433
- plainGreedy: stepSampler.isPlainGreedy,
434
- onToken,
482
+ ...(method ? { method, onToken } : { sample: sampling!.sample,
483
+ plainGreedy: sampling!.plainGreedy, onToken: sampling!.onToken }),
435
484
  onAdmitted: closeAdmission,
436
485
  trace,
437
486
  ...(signal ? { signal } : {}),
@@ -445,7 +494,7 @@ export class GenerationGateway {
445
494
  });
446
495
  } finally {
447
496
  closeAdmission?.();
448
- stepSampler.dispose();
497
+ sampling?.dispose();
449
498
  // The scheduler never owns per-row grammar state.
450
499
  options.grammar?.dispose();
451
500
  }
@@ -460,6 +509,8 @@ export class GenerationGateway {
460
509
  prefillTps: st.prefillMs > 0 ? ((st.promptTokens - st.cachedTokens) / st.prefillMs) * 1000 : 0,
461
510
  decodeTps: st.decodeMs > 0 && st.generatedTokens > 1 ? ((st.generatedTokens - 1) / st.decodeMs) * 1000 : 0,
462
511
  cacheTokens: [],
512
+ ...(st.spec ? { spec: st.spec } : {}),
513
+ ...(st.fill ? { fill: st.fill } : {}),
463
514
  };
464
515
  }
465
516
 
@@ -470,7 +521,6 @@ export class GenerationGateway {
470
521
  maxBatch: this.#batch,
471
522
  stateCodecs: this.opts.stateCodecs,
472
523
  kvBudgetBytes: this.opts.kvBudgetBytes,
473
- memoryBudget: this.opts.memoryBudget,
474
524
  // Phase 3.1: the batchable kvConfig composition is applied by the
475
525
  // scheduler (solo rows convert at serial chunk boundaries, then
476
526
  // merge as quantized triples). Only threaded when the scheme
@@ -21,9 +21,14 @@ export interface BuiltPrompt {
21
21
  diffusionPixels: MlxArray | null;
22
22
  }
23
23
 
24
+ /** Enter the model's execution domain for native preparation. Fetching and
25
+ * container transcoding stay outside this callback. */
26
+ export type PromptNativeWork = <T>(work: () => Promise<T>) => Promise<T>;
27
+
24
28
  export interface ModelPromptBuilder {
25
29
  (body: ChatRequestParams, tools: ChatRequestParams["tools"] | null,
26
- ownership: RequestOwnership, prep: RequestPrep): Promise<BuiltPrompt>;
30
+ ownership: RequestOwnership, prep: RequestPrep, nativeWork?: PromptNativeWork,
31
+ objects?: import("../contracts/object-cache").ObjectCache<import("../backends/mlx/checkpoint-state").CheckpointAttachment[]>): Promise<BuiltPrompt>;
27
32
  }
28
33
 
29
34
  /** Model-owned native services. A replacement supplies this one binding;
@@ -35,6 +40,7 @@ export interface ModelServingBinding {
35
40
  createSerial(services: MlxSerialServices): SerialRun;
36
41
  readonly buildPrompt: ModelPromptBuilder;
37
42
  restore(store: SsdCacheStore, entry: SsdIndexEntry): ReturnType<SsdCacheStore["restore"]>;
43
+ restoreAsync?(store: SsdCacheStore, entry: SsdIndexEntry): ReturnType<SsdCacheStore["restoreAsync"]>;
38
44
  signal(promptIds: number[], bins: number, minimum: number): Promise<{ bins: number[]; vocab: number }>;
39
45
  diagnostics(): Record<string, unknown>;
40
46
  readonly discovery: { readonly adapters: boolean; readonly training: boolean;
@@ -73,10 +73,9 @@ export interface ServerContext<Model = RuntimeModel> {
73
73
  * optiq serve injects these as server defaults (gen_config.py);
74
74
  * explicit request fields always win. */
75
75
  genDefaults: GenSamplingDefaults;
76
- /** Speculative decoding (`serve --draft-model`, mlx_lm.server parity).
77
- * Server-level: when set, EVERY request routes to the serial lane
78
- * (upstream: is_batchable = draft is None) and spec-eligible ones decode
79
- * through src/spec/serve-loop.ts. null = no draft configured. */
76
+ /** Spec-eligible requests use src/spec/serve-loop.ts; ordinary fallbacks
77
+ * use their own feature-compatible scheduling mechanism.
78
+ * null = no draft configured. */
80
79
  draft?: {
81
80
  provider: import("../spec/source").DraftProvider;
82
81
  numDraftTokens: number;
@@ -17,11 +17,11 @@ export async function acquireReservation(pool: Pick<AdmissionPool, "acquire">, s
17
17
  finally { signal?.removeEventListener("abort", abort); }
18
18
  }
19
19
 
20
- /** One retained media preparation at a time; grammar rows may form a batch. */
20
+ /** Bound retained preparations independently from the execution queue. */
21
21
  export function createPreparationExecutor(
22
- run: PreparationExecutor["run"], grammarCapacity: number,
22
+ run: PreparationExecutor["run"], grammarCapacity: number, mediaCapacity = 1,
23
23
  ): PreparationExecutor & { close(): void } {
24
- const media = new AdmissionPool(1);
24
+ const media = new AdmissionPool(Math.max(1, mediaCapacity));
25
25
  const constraints = new AdmissionPool(Math.max(1, grammarCapacity));
26
26
  return {
27
27
  run,
@@ -10,16 +10,27 @@ export type P2RTracePhase =
10
10
  | "request.prompt_prepare"
11
11
  | "completion.total"
12
12
  | "completion.placement"
13
+ | "engine.request_wait"
13
14
  | "engine.admission_wait"
15
+ | "engine.mixed_forward"
14
16
  | "cache.lookup_restore"
17
+ | "cache.prefetch"
15
18
  | "prefill.total"
16
19
  | "prefill.batch_setup"
17
20
  | "prefill.chunk"
21
+ | "prefill.row_sync"
22
+ | "prefill.forward"
23
+ | "prefill.evaluate"
24
+ | "prefill.checkpoint"
25
+ | "prefill.project"
26
+ | "prefill.complete"
27
+ | "prefill.companion"
18
28
  | "prefill.kv_maintenance"
19
29
  | "token_zero.total"
20
30
  | "token_zero.forward"
21
31
  | "token_zero.head"
22
32
  | "token_zero.sample"
33
+ | "response.token_route"
23
34
  | "response.first_write"
24
35
  | "response.final_write";
25
36
 
@@ -42,6 +53,8 @@ export interface P2RTraceRecord {
42
53
  route: string;
43
54
  clock: "monotonic-ms";
44
55
  outcome: P2RTraceOutcome;
56
+ /** Process-local monotonic origin; aligns concurrent requests in one server. */
57
+ startedAtMs?: number;
45
58
  totalMs: number;
46
59
  events: P2RTraceEvent[];
47
60
  attributes?: P2RTraceAttributes;
@@ -129,6 +142,7 @@ export class PromptResponseTrace {
129
142
  route: this.route,
130
143
  clock: "monotonic-ms",
131
144
  outcome,
145
+ startedAtMs: this.#startedAt,
132
146
  totalMs,
133
147
  events: [...this.#events].sort((a, b) => a.startMs - b.startMs),
134
148
  ...(attributes ? { attributes } : {}),
@@ -33,12 +33,13 @@ export interface RequestPlanInput {
33
33
  promptIds: number[];
34
34
  options: GenerateOptions & { stopSequences: string[] };
35
35
  requestedMaxTokens: number;
36
- maxSafeContext: number;
36
+ contextLimit: number | null;
37
37
  stream: boolean;
38
38
  wantLogprobs: boolean;
39
39
  topLogprobs: number;
40
40
  adapterIds: string[];
41
41
  hasVision: boolean;
42
+ hasPreparedPrefixIdentity?: boolean;
42
43
  userSeed: boolean;
43
44
  hasGrammar: boolean;
44
45
  hasDraft: boolean;
@@ -114,11 +115,12 @@ export type PlanRequestResult = RequestPlan | RequestRejection;
114
115
  * options, adapter selection, and lane shape are derived here once for chat
115
116
  * and raw completions. */
116
117
  export function planRequest(input: RequestPlanInput): PlanRequestResult {
117
- const available = input.maxSafeContext - input.promptIds.length;
118
+ const available = input.contextLimit === null ? Infinity
119
+ : input.contextLimit - input.promptIds.length;
118
120
  if (available < 1) {
119
121
  return new RequestRejection(
120
122
  `prompt is ${input.promptIds.length} tokens but the memory budget caps ` +
121
- `safe context at ${input.maxSafeContext} — no room to generate; ` +
123
+ `safe context at ${input.contextLimit} — no room to generate; ` +
122
124
  "shorten the prompt or raise --memory-budget",
123
125
  input.ownership,
124
126
  );
@@ -136,6 +138,7 @@ export function planRequest(input: RequestPlanInput): PlanRequestResult {
136
138
 
137
139
  const shape: RequestShape = {
138
140
  hasVision: input.hasVision,
141
+ hasPreparedPrefixIdentity: input.hasPreparedPrefixIdentity,
139
142
  hasAdapters: !!options.adapters?.length,
140
143
  hasRepetitionPenalty: !!options.repetitionPenalty,
141
144
  hasLogitsExtras: !!(
@@ -5,7 +5,7 @@
5
5
  // tool-call stream router. Resolved ONCE per server (createRequestPrep) and
6
6
  // captured by the route handlers. Extracted from src/server.ts (repo-taming
7
7
  // Phase 4).
8
- import type { ToolDefinition } from "../chat-template";
8
+ import type { ChatMessage, ToolDefinition } from "../chat-template";
9
9
  import {
10
10
  fillEchoConfig,
11
11
  fillMaxSpan,
@@ -27,6 +27,7 @@ import {
27
27
  type ChatRequestParams,
28
28
  } from "./chat-request";
29
29
  import type { ServingContext } from "./model-host";
30
+ import type { PromptTokenHistory } from "./generated-token-history";
30
31
  import { selectToolStreamMode, ToolAwareStream, type ToolStreamMode } from "./token-streams";
31
32
 
32
33
  /** The ServerOptions fields request preparation reads (server-wide
@@ -49,6 +50,7 @@ export function createRequestPrep(input: {
49
50
  /** `--max-tokens` / the GLM memory plan's generation cap; undefined = the
50
51
  * surface's own default (chat 65 536, raw completion 512). */
51
52
  defaultGeneratedTokens: number | undefined;
53
+ tokenHistory?: PromptTokenHistory;
52
54
  }) {
53
55
  const { ctx, serverOptions, kvScheme, defaultGeneratedTokens } = input;
54
56
 
@@ -92,10 +94,10 @@ export function createRequestPrep(input: {
92
94
  ? Math.min(genTemp, NO_THINK_TEMPERATURE)
93
95
  : genTemp;
94
96
  return {
95
- // No invented cap: unset means "generate until EOS or the admitted
96
- // context is exhausted" — admission (planRequest) is the real limit
97
- // and clamps to maxSafeContext − promptLen. Only an explicit request
98
- // max_tokens or the operator's --max-tokens narrows it further.
97
+ cacheSessionId: typeof req.session_id === "string" ? req.session_id :
98
+ typeof req.prompt_cache_key === "string" ? req.prompt_cache_key : undefined,
99
+ // Unset means generate until EOS/stop. Only a client cap or an explicit
100
+ // operator limit constrains completion length; fit estimates do not.
99
101
  maxTokens: req.max_completion_tokens ?? req.max_tokens ??
100
102
  defaultGeneratedTokens,
101
103
  temperature: req.temperature ?? serverOptions.defaultTemperature ?? defaultTemp,
@@ -193,7 +195,8 @@ export function createRequestPrep(input: {
193
195
  const ids = ctx.tokenizer.encode(rendered);
194
196
  // template includes <bos>; tokenizer post-processor also prepends one
195
197
  const trimmed = ids[0] === ids[1] && ids[0] === ctx.tokenizer.bosTokenId ? ids.slice(1) : ids;
196
- return { ids: trimmed, startInThinking: promptEndsInOpenThink(rendered) };
198
+ return { ids: input.tokenHistory?.resolve(rendered, trimmed) ?? trimmed,
199
+ startInThinking: promptEndsInOpenThink(rendered) };
197
200
  };
198
201
 
199
202
  /** STABLE cache boundary: the prompt's tail is a generation primer (e.g.
@@ -221,18 +224,32 @@ export function createRequestPrep(input: {
221
224
  if (primer !== undefined) return Math.max(0, trimmed.length - primer);
222
225
  let stableLen = trimmed.length;
223
226
  try {
224
- const probe = ctx.tokenizer.encode(
225
- ctx.template.render(
226
- [...normalizeMessages(req.messages), { role: "assistant", content: "x" }],
227
- opts,
228
- ),
229
- );
230
- const probeTrimmed =
231
- probe[0] === probe[1] && probe[0] === ctx.tokenizer.bosTokenId ? probe.slice(1) : probe;
232
- let i = 0;
233
- const n = Math.min(trimmed.length, probeTrimmed.length);
234
- while (i < n && trimmed[i] === probeTrimmed[i]) i++;
235
- stableLen = i;
227
+ const canonical = ctx.tokenizer.encode(ctx.template.render(normalizeMessages(req.messages), opts));
228
+ const current = canonical[0] === canonical[1] && canonical[0] === ctx.tokenizer.bosTokenId
229
+ ? canonical.slice(1) : canonical;
230
+ const replies: ChatMessage[] = [{ role: "assistant", content: "x" }];
231
+ // Some templates retain an empty think primer for text replies but
232
+ // omit it before a tool call. Both reply forms must preserve the
233
+ // boundary retained by an untrimmable recurrent cache.
234
+ if (tools?.length) replies.push({ role: "assistant", content: null, tool_calls: [{
235
+ id: "cache-boundary-probe", type: "function",
236
+ function: { name: tools[0]!.function.name, arguments: {} },
237
+ }] });
238
+ let i = current.length;
239
+ for (const reply of replies) {
240
+ const probe = ctx.tokenizer.encode(ctx.template.render([
241
+ ...normalizeMessages(req.messages), reply, { role: "user", content: "x" },
242
+ ], opts));
243
+ const probeTrimmed = probe[0] === probe[1] && probe[0] === ctx.tokenizer.bosTokenId ? probe.slice(1) : probe;
244
+ let common = 0;
245
+ const n = Math.min(i, probeTrimmed.length);
246
+ while (common < n && current[common] === probeTrimmed[common]) common++;
247
+ i = common;
248
+ }
249
+ // Generated-history provenance may use a different BPE segmentation
250
+ // inside old turns. Measure the primer canonically, then subtract it
251
+ // from the actual request IDs that inference will receive.
252
+ stableLen = Math.max(0, trimmed.length - (current.length - i));
236
253
  // Memoize the mode's primer length (sanity-capped: a "primer" longer
237
254
  // than 64 tokens means the probe diverged for content reasons —
238
255
  // don't generalize that).
@@ -273,10 +290,10 @@ export function createRequestPrep(input: {
273
290
  * when the feature is off or the request's shape refuses it.
274
291
  *
275
292
  * Refusals owned here (body-level); the ones only ChatStage can see —
276
- * a compiled grammar, media prompts, a mounted draft model, a quantized KV
277
- * scheme — are applied there. Continuous (batch) placement needs no
278
- * refusal: generate() is the only fill site, so a batched request simply
279
- * does not fill.
293
+ * a compiled grammar, media prompts and a mounted draft model — are applied
294
+ * there. The execution binding owns cache-format and method support. Strict
295
+ * shared execution keeps the sampler's absolute token positions, including
296
+ * known tokens; an explicit seed therefore remains replayable.
280
297
  *
281
298
  * MLX_BUN_FILL=echo additionally arms the echo index (K3c, Lab tier): the
282
299
  * strict rows keep policy "assert", and copied spans ride the same
@@ -289,9 +306,7 @@ export function createRequestPrep(input: {
289
306
  const mode = resolveFillMode();
290
307
  if (mode === "off") return null;
291
308
  if (!tools?.length) return null;
292
- // A user-fixed seed means reproducibility: the sampler's step index would
293
- // skip the injected positions, so an identical request could not replay.
294
- if (req.seed !== undefined) return null;
309
+ // Seed support is selected by the execution binding after placement.
295
310
  // Injected tokens are never sampled — they have no logprob row.
296
311
  if (req.logprobs === true) return null;
297
312
  if (typeof req.top_logprobs === "number" && req.top_logprobs > 0) return null;
@@ -16,7 +16,7 @@ export class TextCompletionStage {
16
16
  constructor(
17
17
  private readonly ctx: ServingContext,
18
18
  private readonly prep: RequestPrep,
19
- private readonly maxSafeContext: number,
19
+ private readonly contextLimit: number | null,
20
20
  /** `--max-tokens` / the GLM plan's reservation. No invented fallback:
21
21
  * unset runs to EOS or the admitted context (with no template an EOS
22
22
  * may never come — that run is bounded by admission, the real limit). */
@@ -81,7 +81,7 @@ export class TextCompletionStage {
81
81
  promptIds,
82
82
  options,
83
83
  requestedMaxTokens,
84
- maxSafeContext: this.maxSafeContext,
84
+ contextLimit: this.contextLimit,
85
85
  stream: request.stream,
86
86
  wantLogprobs,
87
87
  topLogprobs,