mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
package/src/lora.ts CHANGED
@@ -15,6 +15,7 @@ import { resolve } from "node:path";
15
15
  import { ptr, read } from "bun:ffi";
16
16
  import { MlxArray, cpuStream } from "./mlx/array";
17
17
  import { C } from "./mlx/ffi";
18
+ import { transposeAxes } from "./mlx/ops";
18
19
  import { SafetensorsFile } from "./safetensors";
19
20
  import type { LoraWeights } from "./model/gemma4";
20
21
  import type { RuntimeModel } from "./model/factory";
@@ -31,6 +32,12 @@ export interface AdapterInfo {
31
32
  /** Adapter tensors for modules outside the 7 target suffixes
32
33
  * (per-layer-input projections etc.) — skipped, like the reference. */
33
34
  skippedTensors: number;
35
+ /** Actual resident bytes of this adapter's mounted lora_a/lora_b arrays
36
+ * (sum of MlxArray.nbytes — the real RAM cost while mounted, not the
37
+ * on-disk safetensors size). Web chat's adapter routing table (plan
38
+ * §5.6/§9 Phase 2) shows this per loaded adapter instead of guessing
39
+ * from file size. */
40
+ ramBytes: number;
34
41
  }
35
42
 
36
43
  /** Read every tensor from an adapter safetensors file (small, f32) into
@@ -74,21 +81,26 @@ export function loadAdapterTensors(file: string): Map<string, MlxArray> {
74
81
  }
75
82
 
76
83
  /** Adapter scale from its config: mlx-lm writes lora_parameters.scale;
77
- * PEFT writes lora_alpha + r (scale = alpha / r). Reference: mount.py.
78
- * `rsLora` (recorded at train time) means the effective per-layer scale is
79
- * α/√rank — the caller divides by √(that layer's rank). */
84
+ * PEFT writes lora_alpha + r and optionally use_rslora. For ordinary PEFT
85
+ * return alpha/r as the scale. For PEFT rsLoRA return alpha and set rsLora,
86
+ * so the caller's one per-layer division produces alpha/√rank exactly once.
87
+ * Internal configs already store their pre-√rank scale directly. */
80
88
  export async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null; rsLora: boolean }> {
81
89
  for (const name of ["optiq_lora_config.json", "adapter_config.json"]) {
82
90
  const f = Bun.file(`${dir}/${name}`);
83
91
  if (await f.exists()) {
84
92
  const cfg = (await f.json()) as Record<string, any>;
85
93
  const lp = cfg.lora_parameters;
86
- const rsLora = Boolean(cfg.rs_lora ?? lp?.rs_lora ?? false);
94
+ const rsLora = Boolean(cfg.rs_lora ?? lp?.rs_lora ?? cfg.use_rslora ?? false);
87
95
  if (lp && typeof lp === "object")
88
96
  return { scale: Number(lp.scale ?? 20.0), rank: lp.rank ?? null, rsLora };
89
97
  const alpha = Number(cfg.lora_alpha ?? 16);
90
98
  const r = Number(cfg.r ?? 8);
91
- return { scale: r ? alpha / r : 1.0, rank: r || null, rsLora };
99
+ return {
100
+ scale: rsLora ? alpha : r ? alpha / r : 1.0,
101
+ rank: r || null,
102
+ rsLora,
103
+ };
92
104
  }
93
105
  }
94
106
  throw new Error(`no adapter_config.json in ${dir}`);
@@ -209,7 +221,12 @@ export class AdapterManager {
209
221
 
210
222
  // Group adapter tensors into (modulePath → {a, b}), probing both the
211
223
  // pure-LLM and VLM-wrapped prefixes like the reference _find_weight_pair.
212
- const pairs = new Map<string, { a?: MlxArray; b?: MlxArray }>();
224
+ const pairs = new Map<string, {
225
+ a?: MlxArray;
226
+ b?: MlxArray;
227
+ aIsPeft?: boolean;
228
+ bIsPeft?: boolean;
229
+ }>();
213
230
  let skipped = 0;
214
231
  const ourPrefix = `${this.#model.prefixBase}.layers.`;
215
232
  const altPrefix = ourPrefix.startsWith("language_model.")
@@ -219,11 +236,23 @@ export class AdapterManager {
219
236
  const m = name.match(/^(.*)\.(lora_a|lora_A|lora_b|lora_B)(\.weight)?$/);
220
237
  if (!m) { skipped++; arr.dispose(); continue; }
221
238
  let modulePath = m[1]!;
239
+ // Standard PEFT state dicts wrap the model path in
240
+ // `base_model.model.`. The remainder is the underlying HF module path
241
+ // (`model.layers...` or `language_model.model.layers...`).
242
+ if (modulePath.startsWith("base_model.model."))
243
+ modulePath = modulePath.slice("base_model.model.".length);
222
244
  if (modulePath.startsWith(altPrefix))
223
245
  modulePath = ourPrefix + modulePath.slice(altPrefix.length);
224
246
  if (!targets.has(modulePath)) { skipped++; arr.dispose(); continue; }
225
247
  const slot = pairs.get(modulePath) ?? {};
226
- if (m[2]!.toLowerCase() === "lora_a") slot.a = arr; else slot.b = arr;
248
+ const peftName = m[3] === ".weight" || m[2] === "lora_A" || m[2] === "lora_B";
249
+ if (m[2]!.toLowerCase() === "lora_a") {
250
+ slot.a = arr;
251
+ slot.aIsPeft = peftName;
252
+ } else {
253
+ slot.b = arr;
254
+ slot.bIsPeft = peftName;
255
+ }
227
256
  pairs.set(modulePath, slot);
228
257
  }
229
258
 
@@ -234,21 +263,61 @@ export class AdapterManager {
234
263
  for (const { a, b } of pairs.values()) { a?.dispose(); b?.dispose(); }
235
264
  };
236
265
  try {
237
- for (const [modulePath, { a, b }] of pairs) {
266
+ for (const [modulePath, pair] of pairs) {
267
+ const { a, b } = pair;
238
268
  if (!a || !b)
239
269
  throw new Error(`${modulePath}: adapter has only one of lora_a/lora_b`);
240
270
  const linear = targets.get(modulePath)!;
241
- const [aIn, rank] = a.shape as [number, number];
242
- const [bRank, bOut] = b.shape as [number, number];
243
- if (aIn !== linear.inFeatures || bRank !== rank || bOut !== linear.outFeatures)
271
+ if (a.shape.length !== 2 || b.shape.length !== 2)
272
+ throw new Error(
273
+ `${modulePath}: LoRA tensors must be rank-2; got ` +
274
+ `lora_a [${a.shape}] / lora_b [${b.shape}]`,
275
+ );
276
+
277
+ const internalLayout =
278
+ a.shape[0] === linear.inFeatures &&
279
+ b.shape[0] === a.shape[1] &&
280
+ b.shape[1] === linear.outFeatures;
281
+ const peftLayout =
282
+ a.shape[1] === linear.inFeatures &&
283
+ b.shape[1] === a.shape[0] &&
284
+ b.shape[0] === linear.outFeatures;
285
+ if (!internalLayout && !peftLayout)
244
286
  throw new Error(
245
287
  `${modulePath}: shape mismatch — lora_a [${a.shape}] / lora_b [${b.shape}] ` +
246
- `vs base [in ${linear.inFeatures}, out ${linear.outFeatures}]; ` +
247
- `was this adapter trained for a different base model?`,
288
+ `vs base [in ${linear.inFeatures}, out ${linear.outFeatures}]. Expected ` +
289
+ `mlx-lm [in, rank] + [rank, out] or PEFT [rank, in] + [out, rank].`,
248
290
  );
291
+
292
+ // Full-rank square adapters satisfy both shape predicates. In that
293
+ // ambiguous case the PEFT spelling is authoritative; otherwise the
294
+ // unique compatible layout decides.
295
+ const usePeftLayout =
296
+ peftLayout && (!internalLayout || (pair.aIsPeft === true && pair.bIsPeft === true));
297
+ let mountedA = a;
298
+ let mountedB = b;
299
+ if (usePeftLayout) {
300
+ let transposedA: MlxArray | null = null;
301
+ let transposedB: MlxArray | null = null;
302
+ try {
303
+ transposedA = transposeAxes(a, [1, 0]);
304
+ transposedB = transposeAxes(b, [1, 0]);
305
+ } catch (e) {
306
+ transposedA?.dispose();
307
+ transposedB?.dispose();
308
+ throw e;
309
+ }
310
+ mountedA = transposedA;
311
+ mountedB = transposedB;
312
+ pair.a = mountedA;
313
+ pair.b = mountedB;
314
+ a.dispose();
315
+ b.dispose();
316
+ }
317
+ const rank = mountedA.shape[1]!;
249
318
  // rsLoRA: effective per-layer scale is α/√rank (matches training).
250
319
  const effScale = rsLora ? scale / Math.sqrt(rank) : scale;
251
- validated.push({ linear, lw: { a, b, scale: effScale, rank } });
320
+ validated.push({ linear, lw: { a: mountedA, b: mountedB, scale: effScale, rank } });
252
321
  }
253
322
  if (validated.length === 0)
254
323
  throw new Error(
@@ -266,6 +335,7 @@ export class AdapterManager {
266
335
  linear.loraState = this.#model.loraState;
267
336
  }
268
337
 
338
+ const ramBytes = validated.reduce((sum, { lw }) => sum + lw.a.nbytes + lw.b.nbytes, 0);
269
339
  const info: AdapterInfo = {
270
340
  id,
271
341
  path,
@@ -274,6 +344,7 @@ export class AdapterManager {
274
344
  sizeBytes: statSync(weightsFile).size,
275
345
  mountedLayers: validated.length,
276
346
  skippedTensors: skipped,
347
+ ramBytes,
277
348
  };
278
349
  this.#mounted.set(id, info);
279
350
  this.#arrays.set(id, validated.flatMap(({ lw }) => [lw.a, lw.b]));
@@ -5,3 +5,45 @@ declare module "*.md" {
5
5
  const content: string;
6
6
  export default content;
7
7
  }
8
+
9
+ // Same deal for vendored text-imported assets under src/web/vendor/ (see
10
+ // that dir's README) — served as raw strings via `with { type: "text" }`,
11
+ // but bun-types has no ambient declaration for .js/.css read as text.
12
+ // Scoped to the vendor path (not a blanket "*.js"/"*.css") so ordinary JS/CSS
13
+ // module resolution elsewhere in the repo is untouched.
14
+ declare module "./web/vendor/*.js" {
15
+ const content: string;
16
+ export default content;
17
+ }
18
+ declare module "./web/vendor/*.css" {
19
+ const content: string;
20
+ export default content;
21
+ }
22
+
23
+ // The frontend bundle (plan §7/§9 Phase 2 module split): GENERATED from
24
+ // src/web/src/*.ts by scripts/build-web.ts into src/web/app.js, imported as
25
+ // raw text in src/server.ts the same way as the vendored assets above.
26
+ // Must stay a glob ("./web/*.js"), not the exact "./web/app.js" path — TS's
27
+ // ambient-module resolution only matches the real on-disk src/web/app.js
28
+ // against a wildcard pattern here, not an exact-path declaration (verified
29
+ // 2026-07: an exact-path declare module silently failed to match while the
30
+ // file existed, and "cannot find module" once it didn't — a TS quirk, not
31
+ // intentional design; keep the glob if this ever needs revisiting).
32
+ declare module "./web/*.js" {
33
+ const content: string;
34
+ export default content;
35
+ }
36
+
37
+ // PWA sources (plan §9 Phase 3): manifest.webmanifest + icon.svg,
38
+ // text-imported into src/server.ts the same way. Globs, not exact paths —
39
+ // the app.js comment above already found (2026-07) that TS's ambient
40
+ // module resolution only matches a real on-disk file against a wildcard
41
+ // pattern, not an exact-path declaration; same quirk applies here.
42
+ declare module "./web/*.webmanifest" {
43
+ const content: string;
44
+ export default content;
45
+ }
46
+ declare module "./web/*.svg" {
47
+ const content: string;
48
+ export default content;
49
+ }
@@ -0,0 +1,426 @@
1
+ // Remote media (image_url / audio_url) fetching with an SSRF/resource-
2
+ // exhaustion policy. A chat request's URL is attacker-controlled input to
3
+ // a server that may hold credentials for its LAN (the classic target:
4
+ // cloud metadata at 169.254.169.254, router admin pages, other local
5
+ // services), so by default the destination of every hop — the original
6
+ // URL and each redirect — must be a public http(s) host, the whole fetch
7
+ // is wall-clock bounded, and the response is size-capped.
8
+ //
9
+ // Escape hatch: `mlx-bun serve --allow-private-media` (or
10
+ // MLX_BUN_ALLOW_PRIVATE_MEDIA=1) re-permits private/loopback/link-local
11
+ // destinations — this is a local single-user server and pointing it at a
12
+ // NAS or another LAN box is legitimate. Scheme/timeout/size limits still
13
+ // apply.
14
+ //
15
+ // DNS names are resolved and every resolved address is checked before the
16
+ // fetch. The request then dials one of those checked IP literals while
17
+ // preserving the logical Host header and HTTPS SNI, so the transport cannot
18
+ // re-resolve to a private address between validation and connect.
19
+ //
20
+ // This module must stay free of mlx imports so tests/media-fetch.test.ts
21
+ // runs model-free.
22
+
23
+ import { lookup } from "node:dns/promises";
24
+
25
+ export interface MediaFetchPolicy {
26
+ /** Permit private/loopback/link-local destinations (LAN hosts). */
27
+ allowPrivate: boolean;
28
+ /** Optional caller-specific explanation when private destinations fail. */
29
+ privateDestinationHint?: string;
30
+ /** Wall-clock budget for the whole fetch — every redirect hop plus the
31
+ * body read share one AbortSignal.timeout. */
32
+ timeoutMs: number;
33
+ /** Response body cap in bytes (checked against Content-Length up front
34
+ * AND while streaming, since the header is optional/spoofable). */
35
+ maxBytes: number;
36
+ maxRedirects: number;
37
+ }
38
+
39
+ export type RestrictedFetch = (
40
+ input: string | URL,
41
+ init?: BunFetchRequestInit,
42
+ ) => Promise<Response>;
43
+
44
+ export type ResolveHost = (
45
+ hostname: string,
46
+ ) => Promise<ReadonlyArray<{ address: string }>>;
47
+
48
+ export interface RestrictedFetchDependencies {
49
+ fetch: RestrictedFetch;
50
+ resolve: ResolveHost;
51
+ }
52
+
53
+ export interface RestrictedFetchOptions {
54
+ /** Optional caller cancellation, combined with the policy timeout. */
55
+ signal?: AbortSignal;
56
+ /** Request headers applied on every validated redirect hop. */
57
+ headers?: Record<string, string>;
58
+ /** Test seam for deterministic DNS/redirect/body behavior. */
59
+ dependencies?: Partial<RestrictedFetchDependencies>;
60
+ /** Media callers reject non-2xx before reading; web_fetch returns the body. */
61
+ rejectHttpErrors?: boolean;
62
+ }
63
+
64
+ export interface RestrictedFetchResult {
65
+ ok: boolean;
66
+ status: number;
67
+ finalUrl: string;
68
+ contentType: string;
69
+ bytes: Uint8Array;
70
+ }
71
+
72
+ export function defaultMediaFetchPolicy(): MediaFetchPolicy {
73
+ return {
74
+ allowPrivate: process.env.MLX_BUN_ALLOW_PRIVATE_MEDIA === "1",
75
+ // 10 s / 64 MB: well above what a 30 s audio clip (~5 MB of 16 kHz
76
+ // WAV, less compressed) or any supported image needs.
77
+ timeoutMs: 10_000,
78
+ maxBytes: 64 * 1024 * 1024,
79
+ maxRedirects: 5,
80
+ };
81
+ }
82
+
83
+ const PRIVATE_HINT =
84
+ "private/loopback hosts are blocked by default; start the server with " +
85
+ "--allow-private-media (or MLX_BUN_ALLOW_PRIVATE_MEDIA=1) to fetch from LAN hosts";
86
+
87
+ function privateHint(policy: MediaFetchPolicy): string {
88
+ return policy.privateDestinationHint ?? PRIVATE_HINT;
89
+ }
90
+
91
+ function parseIpv4(s: string): number[] | null {
92
+ const parts = s.split(".");
93
+ if (parts.length !== 4) return null;
94
+ const out: number[] = [];
95
+ for (const p of parts) {
96
+ if (!/^\d{1,3}$/.test(p)) return null;
97
+ const n = Number(p);
98
+ if (n > 255) return null;
99
+ out.push(n);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ function blockedIpv4(o: number[]): boolean {
105
+ const [a, b] = o as [number, number];
106
+ if (a === 0 || a === 10 || a === 127) return true; // this-net, 10/8, loopback
107
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10
108
+ if (a === 169 && b === 254) return true; // link-local (cloud metadata lives here)
109
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
110
+ if (a === 192 && b === 168) return true; // 192.168/16
111
+ if (a >= 224) return true; // multicast + reserved + broadcast
112
+ return false;
113
+ }
114
+
115
+ /** Expand an IPv6 literal (with `::` compression, an optional zone index,
116
+ * and an optional dotted-quad tail like ::ffff:127.0.0.1) into its 8
117
+ * 16-bit groups. null = not valid IPv6. */
118
+ function parseIpv6(addr: string): number[] | null {
119
+ let s = addr;
120
+ const pct = s.indexOf("%");
121
+ if (pct !== -1) s = s.slice(0, pct);
122
+ if (s.includes(".")) {
123
+ // rewrite the dotted-quad tail as two hex groups
124
+ const i = s.lastIndexOf(":");
125
+ const v4 = parseIpv4(s.slice(i + 1));
126
+ if (!v4) return null;
127
+ s =
128
+ s.slice(0, i + 1) +
129
+ (((v4[0]! << 8) | v4[1]!).toString(16)) + ":" +
130
+ (((v4[2]! << 8) | v4[3]!).toString(16));
131
+ }
132
+ const halves = s.split("::");
133
+ if (halves.length > 2) return null;
134
+ const toGroups = (part: string): number[] | null => {
135
+ if (part === "") return [];
136
+ const out: number[] = [];
137
+ for (const g of part.split(":")) {
138
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
139
+ out.push(parseInt(g, 16));
140
+ }
141
+ return out;
142
+ };
143
+ const head = toGroups(halves[0]!);
144
+ if (head === null) return null;
145
+ if (halves.length === 1) return head.length === 8 ? head : null;
146
+ const tail = toGroups(halves[1]!);
147
+ if (tail === null) return null;
148
+ if (head.length + tail.length > 7) return null;
149
+ return [...head, ...new Array(8 - head.length - tail.length).fill(0), ...tail];
150
+ }
151
+
152
+ function blockedIpv6(g: number[]): boolean {
153
+ if (g.every((x) => x === 0)) return true; // :: unspecified
154
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return true; // ::1
155
+ if ((g[0]! & 0xffc0) === 0xfe80) return true; // link-local fe80::/10
156
+ if ((g[0]! & 0xfe00) === 0xfc00) return true; // ULA fc00::/7
157
+ if ((g[0]! & 0xff00) === 0xff00) return true; // multicast ff00::/8
158
+ // v4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96) embed an IPv4
159
+ // address in the low 32 bits — judge the embedded address.
160
+ const v4Embedded =
161
+ (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) ||
162
+ (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0));
163
+ if (v4Embedded)
164
+ return blockedIpv4([g[6]! >> 8, g[6]! & 0xff, g[7]! >> 8, g[7]! & 0xff]);
165
+ return false;
166
+ }
167
+
168
+ /** True when `addr` is a private/loopback/link-local/reserved IP literal
169
+ * — or unparseable as an IP at all (fail closed; callers only pass
170
+ * things believed to be addresses). Accepts bracketed IPv6. */
171
+ export function isBlockedAddress(addr: string): boolean {
172
+ let s = addr;
173
+ if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
174
+ const v4 = parseIpv4(s);
175
+ if (v4) return blockedIpv4(v4);
176
+ const v6 = parseIpv6(s);
177
+ if (v6) return blockedIpv6(v6);
178
+ return true;
179
+ }
180
+
181
+ /** Sync per-hop destination check: scheme + hostname-literal policy.
182
+ * Returns a rejection reason or null. DNS names pass here and get their
183
+ * resolved addresses checked in fetchMediaBytes (async). Exported pure
184
+ * so the policy is testable without any network. */
185
+ export function checkMediaUrl(u: URL, policy: MediaFetchPolicy): string | null {
186
+ if (u.protocol !== "http:" && u.protocol !== "https:")
187
+ return `unsupported url scheme "${u.protocol.replace(/:$/, "")}" (only http/https)`;
188
+ const host =
189
+ u.hostname.startsWith("[") && u.hostname.endsWith("]")
190
+ ? u.hostname.slice(1, -1)
191
+ : u.hostname;
192
+ if (!host) return "missing host";
193
+ if (policy.allowPrivate) return null;
194
+ const lower = host.toLowerCase();
195
+ if (lower === "localhost" || lower.endsWith(".localhost"))
196
+ return `destination "${host}" — ${privateHint(policy)}`;
197
+ // WHATWG URL parsing already canonicalized numeric IPv4 forms
198
+ // (http://2130706433/, http://0x7f000001/ → 127.0.0.1).
199
+ if (parseIpv4(lower) || parseIpv6(lower)) {
200
+ if (isBlockedAddress(lower))
201
+ return `destination "${host}" — ${privateHint(policy)}`;
202
+ }
203
+ return null;
204
+ }
205
+
206
+ export async function assertResolvesPublic(
207
+ hostname: string,
208
+ kind: string,
209
+ policy: MediaFetchPolicy,
210
+ resolve: ResolveHost = async (host) =>
211
+ await lookup(host, { all: true, verbatim: true }),
212
+ ): Promise<void> {
213
+ if (policy.allowPrivate) return;
214
+ const host =
215
+ hostname.startsWith("[") && hostname.endsWith("]")
216
+ ? hostname.slice(1, -1)
217
+ : hostname;
218
+ if (parseIpv4(host) || parseIpv6(host)) return; // literal — checked sync
219
+ let addrs: { address: string }[];
220
+ try {
221
+ addrs = [...await resolve(host)];
222
+ } catch {
223
+ throw new Error(`${kind} fetch failed: could not resolve host "${host}"`);
224
+ }
225
+ for (const a of addrs) {
226
+ if (isBlockedAddress(a.address))
227
+ throw new Error(
228
+ `${kind} url rejected: host "${host}" resolves to ${a.address} — ${privateHint(policy)}`,
229
+ );
230
+ }
231
+ }
232
+
233
+ async function resolveFetchAddresses(
234
+ hostname: string,
235
+ kind: string,
236
+ policy: MediaFetchPolicy,
237
+ resolve: ResolveHost,
238
+ ): Promise<string[]> {
239
+ const host =
240
+ hostname.startsWith("[") && hostname.endsWith("]")
241
+ ? hostname.slice(1, -1)
242
+ : hostname;
243
+ if (parseIpv4(host) || parseIpv6(host)) return [host];
244
+ if (policy.allowPrivate) return [];
245
+ let addrs: { address: string }[];
246
+ try {
247
+ addrs = [...await resolve(host)];
248
+ } catch {
249
+ throw new Error(`${kind} fetch failed: could not resolve host "${host}"`);
250
+ }
251
+ if (addrs.length === 0)
252
+ throw new Error(`${kind} fetch failed: host "${host}" resolved to no addresses`);
253
+ for (const a of addrs) {
254
+ if (isBlockedAddress(a.address))
255
+ throw new Error(
256
+ `${kind} url rejected: host "${host}" resolves to ${a.address} — ${privateHint(policy)}`,
257
+ );
258
+ }
259
+ return addrs.map((a) => a.address);
260
+ }
261
+
262
+ /**
263
+ * Fetch an http(s) response through the shared public-destination policy.
264
+ * Every redirect is handled manually and revalidated, caller cancellation
265
+ * shares one signal with the wall-clock timeout, and the body is streamed
266
+ * into a byte-capped buffer.
267
+ */
268
+ export async function fetchRestrictedHttpBytes(
269
+ url: string,
270
+ kind: string,
271
+ policy: MediaFetchPolicy,
272
+ options: RestrictedFetchOptions = {},
273
+ ): Promise<RestrictedFetchResult> {
274
+ let current: URL;
275
+ try {
276
+ current = new URL(url);
277
+ } catch {
278
+ throw new Error(`unsupported ${kind} url scheme: ${url.slice(0, 16)}`);
279
+ }
280
+
281
+ const fetchImpl = options.dependencies?.fetch ??
282
+ ((input: string | URL, init?: BunFetchRequestInit) => fetch(input, init));
283
+ const resolve = options.dependencies?.resolve ??
284
+ (async (host: string) => await lookup(host, { all: true, verbatim: true }));
285
+ const timeout = AbortSignal.timeout(policy.timeoutMs);
286
+ const signal = options.signal
287
+ ? AbortSignal.any([options.signal, timeout])
288
+ : timeout;
289
+ const capMb = Math.round(policy.maxBytes / (1024 * 1024));
290
+ let overCap = false;
291
+
292
+ try {
293
+ for (let hop = 0; ; hop++) {
294
+ const reason = checkMediaUrl(current, policy);
295
+ if (reason) throw new Error(`${kind} url rejected: ${reason}`);
296
+ const addresses = await resolveFetchAddresses(
297
+ current.hostname,
298
+ kind,
299
+ policy,
300
+ resolve,
301
+ );
302
+ const logicalHostname = current.hostname.replace(/^\[|\]$/g, "");
303
+ const connectUrl = new URL(current);
304
+ if (!policy.allowPrivate && addresses.length > 0) {
305
+ const address = addresses[0]!;
306
+ connectUrl.hostname = address.includes(":") ? `[${address}]` : address;
307
+ }
308
+ const headers = new Headers(options.headers);
309
+ if (connectUrl.hostname !== current.hostname && !headers.has("host"))
310
+ headers.set("host", current.host);
311
+ const init: BunFetchRequestInit = {
312
+ redirect: "manual",
313
+ signal,
314
+ headers,
315
+ };
316
+ if (current.protocol === "https:" && connectUrl.hostname !== current.hostname) {
317
+ init.tls = { serverName: logicalHostname };
318
+ }
319
+ const res = await fetchImpl(connectUrl, init);
320
+ if (res.status >= 300 && res.status < 400) {
321
+ const loc = res.headers.get("location");
322
+ if (!loc)
323
+ throw new Error(`${kind} fetch failed: ${res.status} redirect without location`);
324
+ if (hop >= policy.maxRedirects)
325
+ throw new Error(`${kind} fetch failed: more than ${policy.maxRedirects} redirects`);
326
+ try {
327
+ await res.body?.cancel();
328
+ } catch {}
329
+ try {
330
+ current = new URL(loc, current);
331
+ } catch {
332
+ throw new Error(`${kind} fetch failed: malformed redirect location`);
333
+ }
334
+ continue;
335
+ }
336
+
337
+ if (options.rejectHttpErrors && !res.ok) {
338
+ try {
339
+ await res.body?.cancel();
340
+ } catch {}
341
+ throw new Error(`${kind} fetch failed: ${res.status} ${current}`);
342
+ }
343
+
344
+ const declared = Number(res.headers.get("content-length") ?? "0");
345
+ if (Number.isFinite(declared) && declared > policy.maxBytes) {
346
+ overCap = true;
347
+ try {
348
+ await res.body?.cancel();
349
+ } catch {}
350
+ throw new Error(`${kind} response exceeds the ${capMb} MB limit`);
351
+ }
352
+
353
+ let bytes: Uint8Array;
354
+ if (!res.body) {
355
+ bytes = new Uint8Array(await res.arrayBuffer());
356
+ if (bytes.byteLength > policy.maxBytes) {
357
+ overCap = true;
358
+ throw new Error(`${kind} response exceeds the ${capMb} MB limit`);
359
+ }
360
+ } else {
361
+ const reader = res.body.getReader();
362
+ const chunks: Uint8Array[] = [];
363
+ let total = 0;
364
+ for (;;) {
365
+ const { done, value } = await reader.read();
366
+ if (done) break;
367
+ total += value.byteLength;
368
+ if (total > policy.maxBytes) {
369
+ overCap = true;
370
+ try {
371
+ await reader.cancel();
372
+ } catch {}
373
+ throw new Error(`${kind} response exceeds the ${capMb} MB limit`);
374
+ }
375
+ chunks.push(value);
376
+ }
377
+ bytes = new Uint8Array(total);
378
+ let offset = 0;
379
+ for (const chunk of chunks) {
380
+ bytes.set(chunk, offset);
381
+ offset += chunk.byteLength;
382
+ }
383
+ }
384
+
385
+ return {
386
+ ok: res.ok,
387
+ status: res.status,
388
+ finalUrl: current.href,
389
+ contentType: res.headers.get("content-type") ?? "",
390
+ bytes,
391
+ };
392
+ }
393
+ } catch (error) {
394
+ if (timeout.aborted && !options.signal?.aborted && !overCap)
395
+ throw new Error(
396
+ `${kind} fetch timed out after ${policy.timeoutMs} ms: ${url.slice(0, 200)}`,
397
+ );
398
+ throw error;
399
+ }
400
+ }
401
+
402
+ /** Fetch image/audio bytes for an OpenAI-style image_url/audio_url part.
403
+ * data: URLs decode locally (no policy — the bytes came in the request);
404
+ * http(s) URLs go through the destination policy above, with every
405
+ * redirect hop re-validated, one wall-clock timeout, and a streaming
406
+ * size cap. Throws Error with a client-presentable message — callers
407
+ * surface it as a 400 (the prompt-build catch in server.ts). */
408
+ export async function fetchMediaBytes(
409
+ url: string,
410
+ kind: "image" | "audio",
411
+ policy: MediaFetchPolicy = defaultMediaFetchPolicy(),
412
+ ): Promise<Uint8Array> {
413
+ if (url.startsWith("data:")) {
414
+ const comma = url.indexOf(",");
415
+ if (comma === -1) throw new Error("malformed data: URL");
416
+ const meta = url.slice(0, comma);
417
+ const body = url.slice(comma + 1);
418
+ if (!meta.includes("base64")) throw new Error("data: URL must be base64");
419
+ return Uint8Array.from(Buffer.from(body, "base64"));
420
+ }
421
+ return (
422
+ await fetchRestrictedHttpBytes(url, kind, policy, {
423
+ rejectHttpErrors: true,
424
+ })
425
+ ).bytes;
426
+ }