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/sampler.ts CHANGED
@@ -9,8 +9,11 @@
9
9
  // and the XTC-off path is unchanged.)
10
10
 
11
11
  import { Dtype } from "./mlx/ffi";
12
+ import { normalizedArgmax } from "./mlx/normalized-argmax";
12
13
  import { MlxArray } from "./mlx/array";
13
14
  import * as ops from "./mlx/ops";
15
+ import type { TokenLogprobs } from "./contracts/generation";
16
+ import type { SampledToken, SamplingSession } from "./contracts/sampling";
14
17
  import { applyCurve, type CurveParams } from "./lab/curve/curve-sampler";
15
18
 
16
19
  /** Resolved HLG sampling config (the user-facing knobs). The mid gain is NOT
@@ -120,10 +123,7 @@ export interface StepExtras {
120
123
  topVals: MlxArray | null;
121
124
  }
122
125
 
123
- export interface StepSample<T> {
124
- token: T;
125
- extras: StepExtras | null;
126
- }
126
+ export type StepSample<T> = SampledToken<T, StepExtras>;
127
127
 
128
128
  interface StepGrammar {
129
129
  readonly isTerminated: boolean;
@@ -161,25 +161,8 @@ export interface NumberStepSamplerConfig extends StepSamplerConfigBase {
161
161
  grammarWait: "before-sample";
162
162
  }
163
163
 
164
- export interface DeviceStepSampler {
165
- readonly isPlainGreedy: boolean;
166
- readonly needsHistory: boolean;
167
- sample(logits: MlxArray, step: number): StepSample<MlxArray>;
168
- seedHistory(tokens: readonly number[]): void;
169
- commitDevice(token: MlxArray): void;
170
- commitNumbers(tokens: readonly number[]): void;
171
- dispose(): void;
172
- }
173
-
174
- export interface NumberStepSampler {
175
- readonly isPlainGreedy: boolean;
176
- readonly needsHistory: boolean;
177
- sample(logits: MlxArray, step: number): Promise<StepSample<number>>;
178
- seedHistory(tokens: readonly number[]): void;
179
- commitDevice(token: MlxArray): void;
180
- commitNumbers(tokens: readonly number[]): void;
181
- dispose(): void;
182
- }
164
+ export type DeviceStepSampler = SamplingSession<MlxArray, MlxArray, StepSample<MlxArray>>;
165
+ export type NumberStepSampler = SamplingSession<MlxArray, MlxArray, Promise<StepSample<number>>>;
183
166
 
184
167
  const GOLDEN = 0x9e3779b97f4a7c15n;
185
168
 
@@ -786,7 +769,7 @@ export function makeLogitsProcessors(opts: LogitsProcessorOptions = {}): LogitsP
786
769
  return out;
787
770
  }
788
771
 
789
- /** Whether a whole [B,V] row can use the scheduler's vectorized argmax path. */
772
+ /** Whether scores can use stateless greedy sampling across independent rows. */
790
773
  export function isPlainGreedy(
791
774
  opts: StepSamplerOptions,
792
775
  hasProcessors = makeLogitsProcessors(opts).length > 0,
@@ -809,6 +792,23 @@ export function disposeStepExtras(extras: StepExtras | null): void {
809
792
  extras.topVals?.dispose();
810
793
  }
811
794
 
795
+ /** Read captured probabilities with their token, without adding casts behind
796
+ * the next decode step. Consumes the capture on success and failure. */
797
+ export function readStepExtras(extras: StepExtras | null): TokenLogprobs | undefined {
798
+ if (!extras) return undefined;
799
+ try {
800
+ const out: TokenLogprobs = {};
801
+ if (extras.sel) out.logprob = extras.sel.toFloat32Host()[0]!;
802
+ if (extras.topIdx && extras.topVals) {
803
+ const ids = extras.topIdx.toIntTokens();
804
+ const vals = extras.topVals.toFloat32Host();
805
+ out.top = Array.from(ids, (id, i) => ({ id, logprob: vals[i]! }))
806
+ .sort((a, b) => b.logprob - a.logprob);
807
+ }
808
+ return out;
809
+ } finally { disposeStepExtras(extras); }
810
+ }
811
+
812
812
  export function makeStepSampler(
813
813
  options: StepSamplerOptions,
814
814
  config: DeviceStepSamplerConfig,
@@ -827,6 +827,7 @@ export function makeStepSampler(
827
827
  const captureSelected = config.captureSelectedLogprob === true;
828
828
  const captureTop = Math.max(0, config.captureTopLogprobs ?? 0);
829
829
  const capture = captureSelected || captureTop > 0;
830
+ const greedyWithoutMetadata = (options.temperature ?? 0) === 0 && !options.curve && !capture && !config.sampler;
830
831
  let history: MlxArray | null = null;
831
832
 
832
833
  const seedHistory = (tokens: readonly number[]): void => {
@@ -889,6 +890,8 @@ export function makeStepSampler(
889
890
  current = next;
890
891
  ownsCurrent = current !== input;
891
892
  }
893
+ if (greedyWithoutMetadata)
894
+ return { token: normalizedArgmax(current), extras: null };
892
895
  logprobs = toLogprobs(current);
893
896
  if (ownsCurrent) current.dispose();
894
897
  current = input;
@@ -941,7 +944,10 @@ export function makeStepSampler(
941
944
  if (config.initialHistory) seedHistory(config.initialHistory);
942
945
 
943
946
  const common = {
947
+ independent: isPlainGreedy(options, processors.length > 0) && !capture && !config.sampler
948
+ ? independentGreedySampling : undefined,
944
949
  isPlainGreedy: isPlainGreedy(options, processors.length > 0),
950
+ capturesLogprobs: capture,
945
951
  needsHistory: processors.length > 0,
946
952
  seedHistory,
947
953
  commitDevice,
@@ -989,7 +995,13 @@ export function makeStepSampler(
989
995
  };
990
996
  }
991
997
 
992
- /** logits [1, V] → logprobs [1, V] (logits - logsumexp). */
998
+ /** Stateless sampling across rows or verification positions. Preserve the
999
+ * normalized-score argmax, including rounding-created ties. */
1000
+ export const independentGreedySampling = Object.freeze({
1001
+ sample: normalizedArgmax,
1002
+ });
1003
+
1004
+ /** logits [..., V] → logprobs [..., V] (logits - logsumexp). */
993
1005
  export function toLogprobs(logits: MlxArray): MlxArray {
994
1006
  const lse = ops.logsumexpAxis(logits, -1, true);
995
1007
  const out = ops.sub(logits, lse);
@@ -0,0 +1,323 @@
1
+ // OpenAI-compatible speech-to-text routes:
2
+ // POST /v1/audio/transcriptions (task transcribe)
3
+ // POST /v1/audio/translations (task translate → English)
4
+ // POST /admin/transcription/unload (explicit page-out of the Whisper weights)
5
+ // POST /v1/audio/sessions · POST /v1/audio/sessions/:id/audio ·
6
+ // POST /v1/audio/sessions/:id/finish · DELETE /v1/audio/sessions/:id
7
+ // (streaming dictation: chunks arrive during capture, each finished 30 s
8
+ // window transcribes immediately, finish returns the whole transcript)
9
+ // Bodies: multipart/form-data (OpenAI: file, model, language, prompt,
10
+ // response_format, temperature, stream, timestamp_granularities[]) or JSON
11
+ // ({ file: <base64 | data: URL>, ...same fields }). Non-standard fields:
12
+ // beam_size, condition_on_previous_text, no_speech_threshold, vocabulary
13
+ // (array of hint terms fitted into the prompt budget; usage reported),
14
+ // faithful (oracle graph instead of the fast path), audio_ctx (Lab), vad /
15
+ // vad_threshold / vad_min_speech_ms / vad_trim (Silero gate: silence returns
16
+ // an empty transcript without running Whisper).
17
+ // Streaming (`stream=true`): SSE `transcript.text.delta` per segment,
18
+ // then `transcript.text.done` carrying the final body.
19
+
20
+ import { formatTranscription, TRANSCRIPTION_FORMATS, toVerboseJson, type TranscriptionFormat } from "../audio/whisper-format";
21
+ import type { WhisperSegment } from "../audio/whisper-transcribe";
22
+ import { TranscriptionError, TranscriptionService, type TranscriptionOutcome, type TranscriptionParams } from "./transcription-service";
23
+
24
+ export type AudioRoute =
25
+ | { kind: "transcriptions"; task: "transcribe" | "translate" }
26
+ | { kind: "unload" }
27
+ | { kind: "session-create" }
28
+ | { kind: "session-audio"; id: string }
29
+ | { kind: "session-finish"; id: string }
30
+ | { kind: "session-delete"; id: string };
31
+
32
+ export function matchAudioRoute(method: string, pathname: string): AudioRoute | null {
33
+ switch (`${method} ${pathname}`) {
34
+ case "POST /v1/audio/transcriptions": return { kind: "transcriptions", task: "transcribe" };
35
+ case "POST /v1/audio/translations": return { kind: "transcriptions", task: "translate" };
36
+ case "POST /admin/transcription/unload": return { kind: "unload" };
37
+ case "POST /v1/audio/sessions": return { kind: "session-create" };
38
+ }
39
+ if (pathname.startsWith("/v1/audio/sessions/")) {
40
+ const rest = pathname.slice("/v1/audio/sessions/".length).split("/");
41
+ const id = decodeURIComponent(rest[0] ?? "");
42
+ if (!id) return null;
43
+ if (method === "POST" && rest[1] === "audio" && rest.length === 2) return { kind: "session-audio", id };
44
+ if (method === "POST" && rest[1] === "finish" && rest.length === 2) return { kind: "session-finish", id };
45
+ if (method === "DELETE" && rest.length === 1) return { kind: "session-delete", id };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** Everything the route needs from the host: a service (or a reason there is none). */
51
+ export interface AudioRouteHost {
52
+ service(): Promise<TranscriptionService | null>;
53
+ /** Message for the 503 when no Whisper model is available. */
54
+ unavailableMessage?: string;
55
+ }
56
+
57
+ export interface ParsedAudioRequest {
58
+ audio: Uint8Array;
59
+ format: TranscriptionFormat;
60
+ stream: boolean;
61
+ params: TranscriptionParams;
62
+ }
63
+
64
+ const sessionFormats = new Map<string, TranscriptionFormat>();
65
+
66
+ const segmentJson = (s: WhisperSegment) => ({ id: s.id, seek: s.seek, start: s.start, end: s.end, text: s.text, tokens: s.tokens, temperature: s.temperature, avg_logprob: s.avgLogprob, compression_ratio: s.compressionRatio, no_speech_prob: s.noSpeechProb, ...(s.words ? { words: s.words } : {}) });
67
+
68
+ function errorJson(message: string, status: number, type = "invalid_request_error"): Response {
69
+ return Response.json({ error: { message, type } }, { status });
70
+ }
71
+
72
+ const B64_MAX = 200 * 1024 * 1024;
73
+
74
+ function decodeBase64Audio(v: unknown): Uint8Array | null {
75
+ if (typeof v !== "string" || v.length === 0 || v.length > B64_MAX * 1.4) return null;
76
+ const data = v.startsWith("data:") ? v.slice(v.indexOf(",") + 1) : v;
77
+ try {
78
+ return new Uint8Array(Buffer.from(data, "base64"));
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function num(v: unknown): number | null {
85
+ if (v === null || v === undefined || v === "") return null;
86
+ const n = typeof v === "number" ? v : Number(v);
87
+ return Number.isFinite(n) ? n : null;
88
+ }
89
+
90
+ function bool(v: unknown): boolean | null {
91
+ if (typeof v === "boolean") return v;
92
+ if (v === "true" || v === "1") return true;
93
+ if (v === "false" || v === "0") return false;
94
+ return null;
95
+ }
96
+
97
+ /** Decoding params shared by one-shot requests and sessions (no audio). */
98
+ function parseParams(fields: Record<string, unknown>, task: "transcribe" | "translate"): TranscriptionParams | Response {
99
+ const language = typeof fields.language === "string" && fields.language.trim() ? fields.language.trim() : null;
100
+ const temperature = num(fields.temperature);
101
+ if (temperature !== null && (temperature < 0 || temperature > 1)) return errorJson("temperature must be in [0, 1]", 400);
102
+ const beamSize = num(fields.beam_size);
103
+ if (beamSize !== null && (!Number.isInteger(beamSize) || beamSize < 1 || beamSize > 16))
104
+ return errorJson("beam_size must be an integer in [1, 16]", 400);
105
+ if (temperature !== null && temperature > 0 && beamSize !== null && beamSize > 1)
106
+ return errorJson("beam_size requires temperature 0", 400);
107
+ const vocabulary = Array.isArray(fields.vocabulary)
108
+ ? (fields.vocabulary as unknown[]).map(String)
109
+ : typeof fields.vocabulary === "string" && fields.vocabulary.trim()
110
+ ? fields.vocabulary.split(",").map((s) => s.trim()).filter(Boolean)
111
+ : undefined;
112
+ if (vocabulary && (vocabulary.length > 8192 || vocabulary.some((t) => t.length > 16384)))
113
+ return errorJson("vocabulary: at most 8192 terms of at most 16 KiB each", 400);
114
+ const noSpeech = num(fields.no_speech_threshold);
115
+ const vadOn = bool(fields.vad) ?? false;
116
+ const vadThreshold = num(fields.vad_threshold);
117
+ if (vadThreshold !== null && (vadThreshold <= 0 || vadThreshold >= 1)) return errorJson("vad_threshold must be in (0, 1)", 400);
118
+ const vadMinSpeech = num(fields.vad_min_speech_ms);
119
+ const audioCtx = num(fields.audio_ctx);
120
+ if (audioCtx !== null && (!Number.isInteger(audioCtx) || audioCtx < 64 || audioCtx > 1500))
121
+ return errorJson("audio_ctx must be an integer in [64, 1500]", 400);
122
+ const granularities = Array.isArray(fields.timestamp_granularities) ? (fields.timestamp_granularities as string[]) : [];
123
+ return {
124
+ task, language,
125
+ prompt: typeof fields.prompt === "string" && fields.prompt.trim() ? fields.prompt : null,
126
+ vocabulary, temperature, beamSize,
127
+ conditionOnPreviousText: bool(fields.condition_on_previous_text) ?? true,
128
+ ...(noSpeech !== null ? { noSpeechThreshold: noSpeech } : {}),
129
+ withoutTimestamps: bool(fields.without_timestamps) ?? false,
130
+ vad: vadOn ? { threshold: vadThreshold ?? undefined, minSpeechMs: vadMinSpeech ?? undefined, trim: bool(fields.vad_trim) ?? false } : null,
131
+ wordTimestamps: granularities.includes("word"),
132
+ faithful: bool(fields.faithful) ?? false,
133
+ audioCtx,
134
+ };
135
+ }
136
+
137
+ /** Parse a multipart or JSON transcription request into audio bytes + params. */
138
+ export async function parseAudioRequest(request: Request, task: "transcribe" | "translate"): Promise<ParsedAudioRequest | Response> {
139
+ const ct = request.headers.get("content-type") ?? "";
140
+ let fields: Record<string, unknown> = {};
141
+ let audio: Uint8Array | null = null;
142
+ if (ct.includes("multipart/form-data")) {
143
+ let form: FormData;
144
+ try { form = await request.formData(); }
145
+ catch (e) { return errorJson(`invalid multipart body: ${(e as Error).message}`, 400); }
146
+ const file = form.get("file");
147
+ if (!(file instanceof Blob)) return errorJson("`file` (audio upload) is required", 400);
148
+ audio = new Uint8Array(await file.arrayBuffer());
149
+ for (const [k, v] of form.entries()) {
150
+ if (k === "file") continue;
151
+ if (k.endsWith("[]")) {
152
+ const key = k.slice(0, -2);
153
+ (fields[key] ??= [] as string[]);
154
+ (fields[key] as string[]).push(String(v));
155
+ } else fields[k] = typeof v === "string" ? v : String(v);
156
+ }
157
+ } else if (ct.includes("application/json")) {
158
+ try { fields = (await request.json()) as Record<string, unknown>; }
159
+ catch { return errorJson("invalid JSON body", 400); }
160
+ const raw = fields.file ?? (fields.input_audio as { data?: unknown } | undefined)?.data ?? fields.audio;
161
+ audio = decodeBase64Audio(raw);
162
+ if (!audio) return errorJson("`file` must be base64 audio (optionally a data: URL)", 400);
163
+ } else {
164
+ return errorJson("content-type must be multipart/form-data or application/json", 415);
165
+ }
166
+ if (audio.length === 0) return errorJson("empty audio upload", 400);
167
+ const format = (fields.response_format as string | undefined) ?? "json";
168
+ if (!TRANSCRIPTION_FORMATS.includes(format as TranscriptionFormat))
169
+ return errorJson(`response_format must be one of ${TRANSCRIPTION_FORMATS.join(", ")}`, 400);
170
+ const params = parseParams(fields, task);
171
+ if (params instanceof Response) return params;
172
+ const stream = bool(fields.stream) ?? false;
173
+ if (params.wordTimestamps && format !== "verbose_json" && !stream)
174
+ return errorJson("timestamp_granularities[]=word requires response_format=verbose_json", 400);
175
+ return { audio, format: format as TranscriptionFormat, stream, params };
176
+ }
177
+
178
+ /** Body for a finished transcription in the requested format. */
179
+ export function transcriptionResponse(outcome: TranscriptionOutcome, format: TranscriptionFormat, task: string): Response {
180
+ const extra = {
181
+ model: outcome.modelId,
182
+ language: outcome.result.language,
183
+ duration: outcome.durationSeconds,
184
+ timings: outcome.timings,
185
+ ...(outcome.vocabulary ? { vocabulary: outcome.vocabulary } : {}),
186
+ ...(outcome.vad ? { vad: outcome.vad } : {}),
187
+ };
188
+ const headers: Record<string, string> = {
189
+ "x-mlx-bun-language": outcome.result.language,
190
+ "x-mlx-bun-timings": JSON.stringify(outcome.timings),
191
+ };
192
+ if (format === "json")
193
+ return Response.json({ text: outcome.result.text.trim(), mlx_bun: extra }, { headers });
194
+ if (format === "verbose_json")
195
+ return Response.json({ ...toVerboseJson(outcome.result, outcome.durationSeconds, task), mlx_bun: extra }, { headers });
196
+ const body = formatTranscription(outcome.result, format, outcome.durationSeconds, task);
197
+ const type = format === "srt" ? "application/x-subrip" : format === "vtt" ? "text/vtt" : "text/plain";
198
+ return new Response(body, { headers: { ...headers, "content-type": `${type}; charset=utf-8` } });
199
+ }
200
+
201
+ export async function handleAudioRoute(url: URL, request: Request, host: AudioRouteHost): Promise<Response | null> {
202
+ const route = matchAudioRoute(request.method, url.pathname);
203
+ if (!route) return null;
204
+ const service = await host.service();
205
+ if (!service)
206
+ return errorJson(
207
+ host.unavailableMessage ??
208
+ "no Whisper model is available — download one (mlx-bun get mlx-community/whisper-large-v3-turbo) or pass --whisper-model",
209
+ 503, "model_unavailable",
210
+ );
211
+ if (route.kind === "unload") {
212
+ const unloaded = service.unload();
213
+ return Response.json({ unloaded, ...service.stats });
214
+ }
215
+ if (route.kind === "session-create") {
216
+ let fields: Record<string, unknown> = {};
217
+ if ((request.headers.get("content-type") ?? "").includes("application/json")) {
218
+ try { fields = (await request.json()) as Record<string, unknown>; }
219
+ catch { return errorJson("invalid JSON body", 400); }
220
+ }
221
+ const params = parseParams(fields, fields.task === "translate" ? "translate" : "transcribe");
222
+ if (params instanceof Response) return params;
223
+ const format = (fields.response_format as string | undefined) ?? "json";
224
+ if (!["json", "verbose_json"].includes(format)) return errorJson("sessions return json or verbose_json", 400);
225
+ try {
226
+ const session = await service.createSession(params);
227
+ sessionFormats.set(session.id, format as TranscriptionFormat);
228
+ return Response.json({ id: session.id, model: service.modelId, vocabulary: session.vocabulary ?? null });
229
+ } catch (e) {
230
+ return transcriptionFailure(e);
231
+ }
232
+ }
233
+ if (route.kind === "session-audio" || route.kind === "session-finish" || route.kind === "session-delete") {
234
+ const session = service.session(route.id);
235
+ if (!session) return errorJson("unknown transcription session", 404);
236
+ if (route.kind === "session-delete") {
237
+ session.close();
238
+ sessionFormats.delete(route.id);
239
+ return new Response(null, { status: 204 });
240
+ }
241
+ if (route.kind === "session-audio") {
242
+ const ct = request.headers.get("content-type") ?? "";
243
+ const bytes = new Uint8Array(await request.arrayBuffer());
244
+ if (bytes.length === 0) return errorJson("empty audio chunk", 400);
245
+ let pcm: Float32Array;
246
+ try {
247
+ if (ct.startsWith("audio/pcm") || ct.startsWith("audio/l32") || ct.includes("float32")) {
248
+ if (bytes.length % 4 !== 0) return errorJson("float32 PCM chunk length must be a multiple of 4", 400);
249
+ pcm = new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
250
+ const rate = Number(/rate=(\d+)/.exec(ct)?.[1] ?? 16000);
251
+ if (rate !== 16000) return errorJson("float32 PCM chunks must be 16 kHz mono", 400);
252
+ } else pcm = await (service.constructor as typeof TranscriptionService).decodeAudio(bytes);
253
+ } catch (e) {
254
+ return transcriptionFailure(e);
255
+ }
256
+ try {
257
+ const r = await session.append(pcm);
258
+ return Response.json({ id: session.id, samples: session.samples, duration: session.durationSeconds, speech: r.speech, segments: r.segments.map(segmentJson) });
259
+ } catch (e) {
260
+ return transcriptionFailure(e);
261
+ }
262
+ }
263
+ try {
264
+ const outcome = await session.finish();
265
+ sessionFormats.delete(route.id);
266
+ return transcriptionResponse(outcome, sessionFormats.get(route.id) ?? "json", session.params.task ?? "transcribe");
267
+ } catch (e) {
268
+ sessionFormats.delete(route.id);
269
+ return transcriptionFailure(e);
270
+ }
271
+ }
272
+ const parsed = await parseAudioRequest(request, route.task);
273
+ if (parsed instanceof Response) return parsed;
274
+ const taskName = route.task;
275
+ if (!parsed.stream) {
276
+ try {
277
+ const outcome = await service.transcribe(parsed.audio, { ...parsed.params, signal: request.signal });
278
+ return transcriptionResponse(outcome, parsed.format, taskName);
279
+ } catch (e) {
280
+ return transcriptionFailure(e);
281
+ }
282
+ }
283
+ // Streaming: one SSE event per finalized segment, then the final body.
284
+ const encoder = new TextEncoder();
285
+ const stream = new ReadableStream<Uint8Array>({
286
+ start: async (controller) => {
287
+ const send = (event: string, data: unknown) =>
288
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
289
+ try {
290
+ const outcome = await service.transcribe(parsed.audio, {
291
+ ...parsed.params,
292
+ signal: request.signal,
293
+ onSegment: (s: WhisperSegment) => {
294
+ if (s.text) send("transcript.text.delta", { delta: s.text, segment: { id: s.id, start: s.start, end: s.end } });
295
+ },
296
+ onProgress: (done, total) => send("transcript.progress", { done, total }),
297
+ });
298
+ const body = parsed.format === "verbose_json"
299
+ ? toVerboseJson(outcome.result, outcome.durationSeconds, taskName)
300
+ : { text: outcome.result.text.trim() };
301
+ send("transcript.text.done", {
302
+ ...body,
303
+ mlx_bun: { model: outcome.modelId, language: outcome.result.language, duration: outcome.durationSeconds, timings: outcome.timings, ...(outcome.vocabulary ? { vocabulary: outcome.vocabulary } : {}) },
304
+ });
305
+ } catch (e) {
306
+ send("error", { message: (e as Error).message });
307
+ } finally {
308
+ controller.close();
309
+ }
310
+ },
311
+ });
312
+ return new Response(stream, {
313
+ headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" },
314
+ });
315
+ }
316
+
317
+ function transcriptionFailure(e: unknown): Response {
318
+ if (e instanceof TranscriptionError) return errorJson(e.message, e.status);
319
+ const message = (e as Error).message ?? String(e);
320
+ if (/cancelled/.test(message)) return errorJson("request cancelled", 499);
321
+ if (/WAV|afconvert|transcode|audio|RIFF/i.test(message)) return errorJson(`undecodable audio: ${message}`, 400);
322
+ return errorJson(message, 500, "server_error");
323
+ }
@@ -7,6 +7,8 @@ import type { HlgConfig } from "../sampler";
7
7
  import { RequestError } from "./pipeline";
8
8
 
9
9
  export interface ChatRequestParams {
10
+ session_id?: string;
11
+ prompt_cache_key?: string;
10
12
  messages: ChatMessage[];
11
13
  stream?: boolean;
12
14
  max_tokens?: number;
@@ -12,7 +12,7 @@ import {
12
12
  } from "./chat-request";
13
13
  import type { InferenceRequest } from "./inference-request";
14
14
  import type { ServingContext } from "./model-host";
15
- import type { BuiltPrompt, ModelPromptBuilder } from "./model-binding";
15
+ import type { BuiltPrompt, ModelPromptBuilder, PromptNativeWork } from "./model-binding";
16
16
  import { modelPromptBuilder } from "../backends/mlx/model-serving";
17
17
  import { RequestError } from "./pipeline";
18
18
  import { RequestOwnership } from "./request-plan";
@@ -23,9 +23,9 @@ export class ChatStage {
23
23
  constructor(
24
24
  private readonly ctx: ServingContext,
25
25
  private readonly prep: RequestPrep,
26
- private readonly promptCache: Pick<PromptCache, "peekPrefixLen">,
26
+ private readonly promptCache: Pick<PromptCache, "peekPrefixLen"> & Partial<Pick<PromptCache, "objects">>,
27
27
  /** Admission ceiling (fit() / the GLM memory plan), resolved once. */
28
- private readonly maxSafeContext: number,
28
+ private readonly contextLimit: number | null,
29
29
  /** `serve --adapter <dir>` startup default; a request's explicit
30
30
  * `adapter` (incl. "none") wins. */
31
31
  private readonly defaultAdapter?: string,
@@ -40,9 +40,14 @@ export class ChatStage {
40
40
  message.content.some((part) => part.type !== "text"));
41
41
  const native = media || body.response_format != null || !!body.guided_grammar ||
42
42
  !!body.guided_regex || !!body.guided_choice?.length || body.structured_outputs != null;
43
+ const nativeWork: PromptNativeWork = async (work) => {
44
+ signal?.throwIfAborted();
45
+ const entered = async () => { signal?.throwIfAborted(); return work(); };
46
+ return this.preparation ? this.preparation.run(entered, signal) : entered();
47
+ };
43
48
  const build = async () => {
44
49
  signal?.throwIfAborted();
45
- const result = await this.runPrepared(request, requestId);
50
+ const result = await this.runPrepared(request, requestId, nativeWork);
46
51
  if (signal?.aborted) {
47
52
  result.plan.ownership.dispose();
48
53
  signal.throwIfAborted();
@@ -52,14 +57,15 @@ export class ChatStage {
52
57
  if (!native || !this.preparation) return build();
53
58
  let reservation = await this.preparation.reserve?.(media ? "media" : "constraint", signal);
54
59
  try {
55
- const result = await this.preparation.run(build, signal);
60
+ const result = await build();
56
61
  if (reservation) result.plan.ownership.retain(reservation);
57
62
  reservation = undefined;
58
63
  return result;
59
64
  } finally { reservation?.dispose(); }
60
65
  }
61
66
 
62
- private async runPrepared(request: ChatRequest, requestId?: string): Promise<InferenceRequest> {
67
+ private async runPrepared(request: ChatRequest, requestId: string | undefined,
68
+ nativeWork: PromptNativeWork): Promise<InferenceRequest> {
63
69
  const { ctx, prep } = this;
64
70
  let body = request.params;
65
71
  const id = requestId ?? `chatcmpl-${crypto.randomUUID()}`;
@@ -83,7 +89,7 @@ export class ChatStage {
83
89
  !!body.guided_regex || !!body.guided_choice?.length ||
84
90
  body.structured_outputs != null;
85
91
  if (grammarReq) {
86
- const g = await prep.compileGrammarForRequest(body);
92
+ const g = await nativeWork(() => prep.compileGrammarForRequest(body));
87
93
  grammarCtrl = ownership.own(g.controller);
88
94
  if (!g.controller && g.degradeHint) {
89
95
  const degraded = applyGrammarDegrade(body, g.degradeHint);
@@ -94,7 +100,7 @@ export class ChatStage {
94
100
 
95
101
  let built: BuiltPrompt;
96
102
  try {
97
- built = await this.buildPrompt(body, tools, ownership, prep);
103
+ built = await this.buildPrompt(body, tools, ownership, prep, nativeWork, this.promptCache.objects);
98
104
  } catch (e) {
99
105
  if (e instanceof RequestError) return reject(e.status, e.message);
100
106
  return reject(400, `prompt build failed: ${(e as Error).message}`);
@@ -131,13 +137,11 @@ export class ChatStage {
131
137
  // Token fast-forwarding (K3, MLX_BUN_FILL=strict): the determined-span
132
138
  // table for this request's tools. Refused for the shapes only this stage
133
139
  // can see — a compiled grammar (forced tokens are its job), media prompts
134
- // (embeddings prefill / mRoPE), a mounted draft model (the spec loop is a
135
- // different executor and never reads options.fill), and quantized-KV
136
- // schemes (post-conversion multi-token append is unvalidated). The
140
+ // (embeddings prefill / mRoPE). Provider and cache-format support
141
+ // are declared by the engine's method binding. The
137
142
  // body-level refusals live in prep.fillPlanFor.
138
143
  let fillSession: import("../fill/fill-session").FillSession | null = null;
139
- if (!grammarCtrl && !vision && !diffusionPixels && !ctx.draft &&
140
- !options.kvBits && !options.kvConfig?.length && !options.turboQuant) {
144
+ if (!grammarCtrl && !vision && !diffusionPixels) {
141
145
  fillSession = prep.fillPlanFor(body, tools, promptIds);
142
146
  if (fillSession) options.fill = fillSession;
143
147
  }
@@ -165,12 +169,13 @@ export class ChatStage {
165
169
  promptIds,
166
170
  options,
167
171
  requestedMaxTokens,
168
- maxSafeContext: this.maxSafeContext,
172
+ contextLimit: this.contextLimit,
169
173
  stream: request.stream,
170
174
  wantLogprobs,
171
175
  topLogprobs,
172
176
  adapterIds,
173
177
  hasVision: !!vision,
178
+ hasPreparedPrefixIdentity: !!vision?.prefixIdentity,
174
179
  userSeed: body.seed !== undefined,
175
180
  hasGrammar: !!grammarCtrl,
176
181
  hasDraft: !!ctx.draft,
@@ -280,9 +280,17 @@ export class CompletionExecutor {
280
280
  control.signal?.throwIfAborted();
281
281
  planned.transferOwnership();
282
282
 
283
+ // Bounded diagnostic of hidden markers versus visible events. No token
284
+ // readbacks are added: these IDs already crossed the sampling interface.
285
+ let traceTokens = control.trace ? 8 : 0;
283
286
  const consumeToken: OnToken = (token, tokenLogprobs) => {
284
287
  logprobs?.push(token, tokenLogprobs);
285
- const pushed = sink.push(token);
288
+ const route = traceTokens ? { token, index: 8 - traceTokens, events: 0 } : undefined;
289
+ const closeRoute = route ? control.trace!.begin("response.token_route", route) : undefined;
290
+ let pushed: ReturnType<CompletionSink["push"]>;
291
+ try { pushed = sink.push(token); if (route) route.events = pushed.events.length; }
292
+ finally { closeRoute?.(); }
293
+ if (traceTokens) traceTokens = pushed.events.length ? 0 : traceTokens - 1;
286
294
  const observed = pushed.events.length
287
295
  ? control.onEvents?.(pushed.events)
288
296
  : undefined;
@@ -30,10 +30,16 @@ export interface DiscoveryRoutes {
30
30
  invalidateLibrary(): void;
31
31
  }
32
32
 
33
+ export interface TranscriptionInfo {
34
+ id: string;
35
+ resident: boolean;
36
+ }
37
+
33
38
  export function createDiscoveryRoutes(
34
39
  ctx: ServingContext,
35
40
  gateway: Pick<GenerationGateway, "batchMode">,
36
41
  startedAt: number,
42
+ transcription: () => Promise<TranscriptionInfo | null> = async () => null,
37
43
  ): DiscoveryRoutes {
38
44
  let libraryCache: { at: number; rows: unknown[] } | null = null;
39
45
 
@@ -106,6 +112,8 @@ export function createDiscoveryRoutes(
106
112
  "POST /v1/messages",
107
113
  "POST /v1/responses",
108
114
  "POST /v1/embeddings",
115
+ "POST /v1/audio/transcriptions",
116
+ "POST /v1/audio/translations",
109
117
  "GET /v1/models",
110
118
  "GET/POST/DELETE /v1/adapters",
111
119
  "GET /health",
@@ -132,6 +140,7 @@ export function createDiscoveryRoutes(
132
140
  top_k: ctx.genDefaults.topK ?? null,
133
141
  };
134
142
  const capabilities = modelServingBinding(ctx).discovery;
143
+ const stt = await transcription();
135
144
  const data: Array<Record<string, unknown>> = [{
136
145
  id: ctx.modelId,
137
146
  object: "model",
@@ -165,9 +174,16 @@ export function createDiscoveryRoutes(
165
174
  audio: !!(ctx.audio || ctx.loadAudio),
166
175
  adapters: capabilities.adapters,
167
176
  training: capabilities.training,
177
+ transcription: stt !== null,
168
178
  },
169
179
  gen_defaults: genDefaults,
170
180
  }];
181
+ if (stt)
182
+ data.push({
183
+ id: stt.id, object: "model", created, owned_by: "mlx-bun",
184
+ transcription: true, resident: stt.resident,
185
+ capabilities: { transcription: true, translation: true, chat_completions: false },
186
+ });
171
187
  try {
172
188
  const { Registry, visionCapable } = await import("../registry");
173
189
  const { supportTier } = await import("../model/support");
@@ -175,7 +191,7 @@ export function createDiscoveryRoutes(
175
191
  try {
176
192
  if (registry.list().length === 0) await registry.scan();
177
193
  for (const model of registry.listCanonical()) {
178
- if (model.repoId === ctx.modelId) continue;
194
+ if (model.repoId === ctx.modelId || model.repoId === stt?.id) continue;
179
195
  const tier = supportTier(model.modelType, model.repoId);
180
196
  if (tier === null) continue;
181
197
  data.push({