mlx-bun 0.4.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 (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
@@ -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,7 +110,7 @@ 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
 
@@ -318,6 +319,19 @@ export class GenerationGateway {
318
319
  try { return await fn(); } finally { lease.dispose(); }
319
320
  }
320
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
+
321
335
  /** Run one generation on the appropriate lane. onToken is invoked per emitted
322
336
  * token (its `false` halts); resolves with stats when the generation ends. */
323
337
  async run(
@@ -331,10 +345,32 @@ export class GenerationGateway {
331
345
  trace?: PromptResponseTrace,
332
346
  ): Promise<GenerateStats> {
333
347
  let reservation;
348
+ const closeRequestWait = trace?.begin("engine.request_wait", { capacity: this.#batch });
334
349
  try { reservation = await acquireReservation(this.#requests, signal); }
335
350
  catch (error) { cleanupFailure(error, () => disposeUnstartedRequest(options, vision)); }
336
- try { return await this.#run(promptIds, options, onToken, vision, shape, placement, signal, trace); }
337
- 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
+ }
338
374
  }
339
375
 
340
376
  async #run(
@@ -347,7 +383,8 @@ export class GenerationGateway {
347
383
  signal?: AbortSignal,
348
384
  trace?: PromptResponseTrace,
349
385
  ): Promise<GenerateStats> {
350
- const disposeUnstarted = () => disposeUnstartedRequest(options, vision);
386
+ const disposeUnstarted = () => disposeUnstartedRequest(options,
387
+ placement.mechanism === "continuous" ? undefined : vision);
351
388
  if (placement.shape !== shape) {
352
389
  disposeUnstarted();
353
390
  throw new Error("generation placement does not belong to this request shape");
@@ -410,8 +447,10 @@ export class GenerationGateway {
410
447
 
411
448
  const adapters = options.adapters?.length ? [...options.adapters] : undefined;
412
449
  const adapterKey = adapters ? JSON.stringify(adapters) : "";
413
- const cacheNamespace = adapters
414
- ? () => this.opts.adapterNamespace?.(adapters) ?? adapterKey : "";
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 : "";
415
454
  const context = adapters
416
455
  ? this.#binding.bindAdapterContext?.(adapters, `adapters:${adapterKey}`) : undefined;
417
456
 
@@ -433,7 +472,8 @@ export class GenerationGateway {
433
472
  });
434
473
  try {
435
474
  st = await this.#ensureScheduler().submit({
436
- promptIds, context, cacheNamespace, prefillChunkSize: options.prefillChunkSize,
475
+ promptIds, context, cacheNamespace, cacheSessionId: options.cacheSessionId, prefillChunkSize: options.prefillChunkSize,
476
+ ...(vision ? { promptInput: this.#binding.mediaInput!(vision) } : {}),
437
477
  continuation: continuation?.continuation,
438
478
  statePolicy: this.#binding.statePolicy?.(placement.execution, options, promptIds.length + (options.maxTokens ?? 512)),
439
479
  compiledDecode: placement.execution?.compiledDecode,
@@ -470,6 +510,7 @@ export class GenerationGateway {
470
510
  decodeTps: st.decodeMs > 0 && st.generatedTokens > 1 ? ((st.generatedTokens - 1) / st.decodeMs) * 1000 : 0,
471
511
  cacheTokens: [],
472
512
  ...(st.spec ? { spec: st.spec } : {}),
513
+ ...(st.fill ? { fill: st.fill } : {}),
473
514
  };
474
515
  }
475
516
 
@@ -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;
@@ -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 } : {}),
@@ -39,6 +39,7 @@ export interface RequestPlanInput {
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;
@@ -137,6 +138,7 @@ export function planRequest(input: RequestPlanInput): PlanRequestResult {
137
138
 
138
139
  const shape: RequestShape = {
139
140
  hasVision: input.hasVision,
141
+ hasPreparedPrefixIdentity: input.hasPreparedPrefixIdentity,
140
142
  hasAdapters: !!options.adapters?.length,
141
143
  hasRepetitionPenalty: !!options.repetitionPenalty,
142
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,6 +94,8 @@ export function createRequestPrep(input: {
92
94
  ? Math.min(genTemp, NO_THINK_TEMPERATURE)
93
95
  : genTemp;
94
96
  return {
97
+ cacheSessionId: typeof req.session_id === "string" ? req.session_id :
98
+ typeof req.prompt_cache_key === "string" ? req.prompt_cache_key : undefined,
95
99
  // Unset means generate until EOS/stop. Only a client cap or an explicit
96
100
  // operator limit constrains completion length; fit estimates do not.
97
101
  maxTokens: req.max_completion_tokens ?? req.max_tokens ??
@@ -191,7 +195,8 @@ export function createRequestPrep(input: {
191
195
  const ids = ctx.tokenizer.encode(rendered);
192
196
  // template includes <bos>; tokenizer post-processor also prepends one
193
197
  const trimmed = ids[0] === ids[1] && ids[0] === ctx.tokenizer.bosTokenId ? ids.slice(1) : ids;
194
- return { ids: trimmed, startInThinking: promptEndsInOpenThink(rendered) };
198
+ return { ids: input.tokenHistory?.resolve(rendered, trimmed) ?? trimmed,
199
+ startInThinking: promptEndsInOpenThink(rendered) };
195
200
  };
196
201
 
197
202
  /** STABLE cache boundary: the prompt's tail is a generation primer (e.g.
@@ -219,18 +224,32 @@ export function createRequestPrep(input: {
219
224
  if (primer !== undefined) return Math.max(0, trimmed.length - primer);
220
225
  let stableLen = trimmed.length;
221
226
  try {
222
- const probe = ctx.tokenizer.encode(
223
- ctx.template.render(
224
- [...normalizeMessages(req.messages), { role: "assistant", content: "x" }],
225
- opts,
226
- ),
227
- );
228
- const probeTrimmed =
229
- probe[0] === probe[1] && probe[0] === ctx.tokenizer.bosTokenId ? probe.slice(1) : probe;
230
- let i = 0;
231
- const n = Math.min(trimmed.length, probeTrimmed.length);
232
- while (i < n && trimmed[i] === probeTrimmed[i]) i++;
233
- 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));
234
253
  // Memoize the mode's primer length (sanity-capped: a "primer" longer
235
254
  // than 64 tokens means the probe diverged for content reasons —
236
255
  // don't generalize that).
@@ -271,10 +290,10 @@ export function createRequestPrep(input: {
271
290
  * when the feature is off or the request's shape refuses it.
272
291
  *
273
292
  * Refusals owned here (body-level); the ones only ChatStage can see —
274
- * a compiled grammar, media prompts, a mounted draft model, a quantized KV
275
- * scheme — are applied there. Continuous (batch) placement needs no
276
- * refusal: generate() is the only fill site, so a batched request simply
277
- * 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.
278
297
  *
279
298
  * MLX_BUN_FILL=echo additionally arms the echo index (K3c, Lab tier): the
280
299
  * strict rows keep policy "assert", and copied spans ride the same
@@ -287,9 +306,7 @@ export function createRequestPrep(input: {
287
306
  const mode = resolveFillMode();
288
307
  if (mode === "off") return null;
289
308
  if (!tools?.length) return null;
290
- // A user-fixed seed means reproducibility: the sampler's step index would
291
- // skip the injected positions, so an identical request could not replay.
292
- if (req.seed !== undefined) return null;
309
+ // Seed support is selected by the execution binding after placement.
293
310
  // Injected tokens are never sampled — they have no logprob row.
294
311
  if (req.logprobs === true) return null;
295
312
  if (typeof req.top_logprobs === "number" && req.top_logprobs > 0) return null;
@@ -0,0 +1,64 @@
1
+ // Whisper-only server: `mlx-bun serve <whisper checkpoint>` starts this
2
+ // instead of the chat server. Routes: the audio routes, /v1/models,
3
+ // /v1, /health, /stats. No chat machinery, no prompt cache — the process
4
+ // idles at a few tens of MB with the weights paged out (see
5
+ // TranscriptionService). This is the footprint sotto wants from a
6
+ // dictation backend.
7
+
8
+ import type { Server } from "bun";
9
+ import pkgJson from "../../package.json" with { type: "json" };
10
+ import { handleAudioRoute } from "./audio-routes";
11
+ import { TranscriptionService } from "./transcription-service";
12
+
13
+ const pkgVersion = (pkgJson as { version: string }).version;
14
+
15
+ export interface TranscriptionServerOptions {
16
+ modelDir: string;
17
+ modelId: string;
18
+ port?: number;
19
+ hostname?: string;
20
+ unixSocket?: string;
21
+ idleUnloadSec?: number;
22
+ resident?: boolean;
23
+ /** Load the weights before serving (default: on first request). */
24
+ preload?: boolean;
25
+ }
26
+
27
+ export async function createTranscriptionServer(opts: TranscriptionServerOptions): Promise<{ server: Server<unknown>; service: TranscriptionService }> {
28
+ const service = new TranscriptionService({
29
+ modelDir: opts.modelDir, modelId: opts.modelId, idleUnloadSec: opts.idleUnloadSec, resident: opts.resident,
30
+ });
31
+ if (opts.preload) await service.ensureLoaded();
32
+ const startedAt = Date.now();
33
+ const server = Bun.serve({
34
+ ...(opts.unixSocket
35
+ ? ({ unix: opts.unixSocket } as unknown as Record<string, never>)
36
+ : { port: opts.port ?? 0, ...(opts.hostname ? { hostname: opts.hostname } : {}) }),
37
+ idleTimeout: 0,
38
+ async fetch(request) {
39
+ const url = new URL(request.url);
40
+ const audio = await handleAudioRoute(url, request, { service: async () => service });
41
+ if (audio) return audio;
42
+ if (request.method === "GET" && url.pathname === "/health")
43
+ return Response.json({ status: "ok", transcription: { ...service.stats, sessions: service.sessionCount } });
44
+ if (request.method === "GET" && url.pathname === "/stats")
45
+ return Response.json({ model: opts.modelId, transcription: service.stats, uptime_s: (Date.now() - startedAt) / 1000 });
46
+ if (request.method === "GET" && url.pathname === "/v1")
47
+ return Response.json({
48
+ name: "mlx-bun", version: pkgVersion, model: opts.modelId, mode: "transcription",
49
+ endpoints: ["POST /v1/audio/transcriptions", "POST /v1/audio/translations", "POST /v1/audio/sessions", "POST /v1/audio/sessions/:id/audio", "POST /v1/audio/sessions/:id/finish", "DELETE /v1/audio/sessions/:id", "POST /admin/transcription/unload", "GET /v1/models", "GET /health", "GET /stats"],
50
+ });
51
+ if (request.method === "GET" && (url.pathname === "/v1/models" || url.pathname.startsWith("/v1/models/")))
52
+ return Response.json({
53
+ object: "list",
54
+ data: [{
55
+ id: opts.modelId, object: "model", created: Math.floor(startedAt / 1000), owned_by: "mlx-bun",
56
+ transcription: true, resident: service.resident,
57
+ capabilities: { transcription: true, translation: true, chat_completions: false },
58
+ }],
59
+ });
60
+ return Response.json({ error: { message: "not found" } }, { status: 404 });
61
+ },
62
+ });
63
+ return { server, service };
64
+ }