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.
- package/README.md +107 -32
- package/THIRD_PARTY_LICENSES.md +10 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +58 -35
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/colibri-glm52-port.md +1479 -0
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +14 -6
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +3 -2
- package/docs/design/ssd-kv-cold-tier.md +130 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/reference/cli.md +63 -18
- package/docs/reference/distribution.md +37 -6
- package/docs/reference/embedding.md +40 -16
- package/docs/reference/features-matrix.md +99 -0
- package/docs/reference/library-api.md +69 -20
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +60 -3
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +717 -24
- package/docs/reference/server-config.md +265 -129
- package/docs/reference/training.md +24 -16
- package/package.json +5 -3
- package/src/anthropic.ts +67 -44
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/chat-template.ts +155 -10
- package/src/cli.ts +483 -113
- package/src/config.ts +116 -24
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +143 -12
- package/src/eval/README.md +7 -6
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +17 -8
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-offload.ts +15 -2
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/flags.ts +13 -0
- package/src/generate.ts +438 -35
- package/src/grammar.ts +585 -0
- package/src/hf-push.ts +1 -1
- package/src/hub-rest.ts +242 -0
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +586 -93
- package/src/lora.ts +85 -14
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +426 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +149 -19
- package/src/mlx/autograd.ts +53 -16
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +153 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +103 -6
- package/src/model/gemma4-base.ts +395 -39
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +76 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +88 -23
- package/src/model/support.ts +19 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +293 -33
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +57 -2
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +1050 -78
- package/src/serve/generation-gateway.ts +252 -24
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1701 -175
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +617 -0
- package/src/spec/source.ts +118 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +111 -35
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +438 -22
- package/src/train/flash-cce.ts +2 -2
- package/src/train/job.ts +0 -12
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +60 -30
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1197 -1791
- package/src/web/app.js +5694 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +300 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/web-tools.ts +54 -2
- package/src/weights.ts +82 -19
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- 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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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 {
|
|
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, {
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
`
|
|
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]));
|
package/src/md-modules.d.ts
CHANGED
|
@@ -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
|
+
}
|