anyformer 0.0.1 → 0.3.1

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.
@@ -0,0 +1,124 @@
1
+ // diskformer: MoE models larger than GPU memory. Weights stay in OPFS; the GPU holds the dense layers, the KV cache
2
+ // and an expert cache sized by the budget. Engines load from the CDN at load() time, at the commit src/sources.js pins;
3
+ // set diskformerBackend.source to load another copy.
4
+
5
+ import { AnyformerError } from '../errors.js';
6
+ import { SOURCES } from '../sources.js';
7
+
8
+ // The GGUFs diskformer's engines are pinned to (GEMMA4_26B_A4B, QWEN36_35B_A3B in engines/*.js), with the smallest budget each
9
+ // has passed its gate at and the measured decode speeds (diskformer README, M4 Pro, 2026-10-05).
10
+ export const DISKFORMER_MODELS = [
11
+ {
12
+ repo: 'google/gemma-4-26B-A4B-it-qat-q4_0-gguf', file: 'gemma-4-26B_q4_0-it.gguf', size: 14439363584,
13
+ engine: 'gemma4_moe_ssd.js', Engine: 'Gemma4MoeSsd', minBudget: 2.5e9,
14
+ speeds: [[2.5e9, 12.4], [4.3e9, 14.2], [7e9, 17.9]],
15
+ },
16
+ {
17
+ repo: 'unsloth/Qwen3.6-35B-A3B-GGUF', file: 'Qwen3.6-35B-A3B-Q8_0.gguf', size: 36903140320,
18
+ engine: 'qwen35_moe_ssd.js', Engine: 'Qwen35MoeSsd', minBudget: 4e9,
19
+ speeds: [[4e9, 9.2]],
20
+ },
21
+ // Qwen3.6's language model with a vision encoder (mmproj, ~1.1 GB, loaded on the first image by ensureVision).
22
+ // From LocalMind's gate (plan/2026-10-14-minicpm-harness, 4 GiB pool, context 512): ~6.1 GB on the GPU (dense 1.59,
23
+ // pool 4.29), 14.1 tok/s over short greedy continuations. The smallest working budget is unmeasured; the floor is
24
+ // Qwen3.6's, the same language-model shape. Only this engine reads its images, and it ranks before diskformer-llama.
25
+ {
26
+ repo: 'openbmb/MiniCPM-V-4.7-35B-A3B-gguf', file: 'MiniCPM-V-4_7-35B-A3B-Q4_0.gguf', size: 19714550688,
27
+ engine: 'qwen35_moe_ssd.js', Engine: 'MiniCpmV47Ssd', minBudget: 4e9,
28
+ speeds: [[6.1e9, 14.1]],
29
+ },
30
+ // Two parts, ingested as one file by the engine's splitFetch (engines/ling_moe_ssd.js LING_FLASH, rev 29edce5).
31
+ // Budget and speed from LocalMind's gate (plan/2026-10-22-ling-harness/results.md): 2.51 GB of dense weights on
32
+ // the GPU plus a 4.29 GB expert pool, 5.53 tok/s decode; the OPFS store is 80.7 GB.
33
+ {
34
+ repo: 'inclusionAI/Ling-3.0-flash-GGUF',
35
+ parts: [
36
+ { file: 'Q4_K_M/Ling-3.0-flash-Q4_K_M-00001-of-00002.gguf', size: 49995534208 },
37
+ { file: 'Q4_K_M/Ling-3.0-flash-Q4_K_M-00002-of-00002.gguf', size: 27014610464 },
38
+ ],
39
+ size: 49995534208 + 27014610464, storeBytes: 80.7e9,
40
+ engine: 'ling_moe_ssd.js', Engine: 'LingMoeSsd', minBudget: 6.8e9,
41
+ speeds: [[6.8e9, 5.53]],
42
+ },
43
+ ];
44
+
45
+ // diskformer's engines refuse a budget with a plain Error ("GPU budget 2.50 GB is too small: … need 3.04 GB, and the
46
+ // expert cache at least 0.05 GB more"). Until they carry a code, read the two numbers to give BUDGET_TOO_SMALL a need.
47
+ function budgetError(e) {
48
+ const m = /GPU budget [\d.]+ GB is too small\b.*?need ([\d.]+) GB.*?at least ([\d.]+) GB more/s.exec(e?.message || '');
49
+ return m && new AnyformerError('BUDGET_TOO_SMALL', `diskformer: ${e.message}`, { cause: e, need: Math.ceil((+m[1] + +m[2]) * 1e9) });
50
+ }
51
+
52
+ const has = (model, file, size) => model.files.gguf.some((f) => f.name === file && f.size === size);
53
+ const match = (model) => DISKFORMER_MODELS.find((m) => m.repo === model.repo &&
54
+ (m.parts ? m.parts.every((p) => has(model, p.file, p.size)) : has(model, m.file, m.size)));
55
+
56
+ // The speed measured at the largest budget not above this one.
57
+ const speedAt = (m, budget) => m.speeds.filter(([b]) => b <= budget).at(-1)?.[1] ?? null;
58
+
59
+ export { budgetError };
60
+
61
+ export const diskformerBackend = {
62
+ id: 'diskformer',
63
+ // Gemma 4's repo is tagged image-text-to-text; the engines run its text turns.
64
+ tasks: ['text-generation', 'image-text-to-text'],
65
+ source: SOURCES.diskformer,
66
+ // Where the engine downloads its GGUF from; null is Hugging Face. A test points it at a local server.
67
+ url: null,
68
+
69
+ canRun(model, caps, { budget, task }) {
70
+ if (!model.formats.includes('gguf')) return { ok: false, code: 'format', reason: 'no GGUF in the repo' };
71
+ if (task && !diskformerBackend.tasks.includes(task)) return { ok: false, code: 'task', reason: `task ${task}; diskformer engines run text generation only` };
72
+ const m = match(model);
73
+ if (!m) return { ok: false, code: 'model', reason: `no diskformer engine for this GGUF; engines cover ${DISKFORMER_MODELS.map((x) => x.repo).join(', ')}` };
74
+ if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
75
+ if (!caps.f16) return { ok: false, code: 'f16', reason: 'the GPU lacks shader-f16' };
76
+ if (!caps.opfs) return { ok: false, code: 'opfs', reason: 'no OPFS (navigator.storage.getDirectory)' };
77
+ if (budget < m.minBudget) return { ok: false, code: 'budget', need: m.minBudget, reason: `a ${(budget / 1e9).toFixed(1)} GB budget is under the ${m.minBudget / 1e9} GB this model has passed its gate at` };
78
+ // Storage quota is not checked: Chrome raises it as the download writes, while the disk has room.
79
+ return { ok: true, estimate: { gpuBytes: budget, diskBytes: m.storeBytes ?? m.size, tokPerSec: speedAt(m, budget) } };
80
+ },
81
+
82
+ // maxCtx sizes the KV cache, which stays on the GPU: the 2.5 GB minimum and the speeds above hold at 1024 tokens,
83
+ // the context diskformer's chat gate runs at. A longer context needs a larger budget.
84
+ // poolBytes: the expert cache size, passed to the engine; the budget then only admits the model (the engine sizes
85
+ // its pool from gpuBudgetBytes when given one, so it gets one or the other).
86
+ // localFile: the GGUF as a File the user picked; the engine ingests it instead of downloading, and refuses a file
87
+ // whose size differs from the one it is pinned to.
88
+ async load(model, { budget, maxCtx = 1024, poolBytes, localFile, onProgress, signal }) {
89
+ const m = match(model);
90
+ const mod = await import(`${diskformerBackend.source}/${m.engine}`);
91
+ let engine;
92
+ try {
93
+ engine = await mod[m.Engine].load(null, {
94
+ // The engines' own default root, named so the store module (src/store.js) lists the same folder.
95
+ ...(poolBytes ? { poolBytes } : { gpuBudgetBytes: budget }), maxCtx, signal, root: 'localmind-ssd',
96
+ ...(localFile && { localFile }),
97
+ ...(diskformerBackend.url && (m.parts ? { urls: m.parts.map((p) => diskformerBackend.url(model, { ...m, file: p.file })) } : { url: diskformerBackend.url(model, m) })),
98
+ onProgress: onProgress && ((e) => onProgress({ backend: 'diskformer', ...e })),
99
+ });
100
+ } catch (e) { throw budgetError(e) || e; }
101
+ // Where the model's reasoning starts and ends, as token ids, so a caller can split it out without parsing text.
102
+ const open = engine.thinkOpenTokenId ?? null;
103
+ return {
104
+ task: 'text-generation',
105
+ engine,
106
+ think: open === null ? null : {
107
+ openId: open, closeId: engine.thinkCloseTokenId ?? null, inPrompt: engine.thinkInPrompt ?? null,
108
+ preamble: engine.thinkPreamble ?? null, always: engine.alwaysThinks ?? null,
109
+ },
110
+ // thinking: false (the default, as on every back end here) answers directly; true lets a model that reasons
111
+ // (Gemma 4, Qwen3.6, Ling) think first. Image blocks ({ type: 'image', rgb, width, height }) pass through to
112
+ // an engine that reads them.
113
+ async *generate(messages, { maxNewTokens = 512, signal, thinking = false } = {}) {
114
+ for await (const r of engine.generate(messages, { maxNewTokens, signal, enableThinking: thinking === true }))
115
+ yield { text: r.text, token: r.token, tokens: r.tokens };
116
+ },
117
+ // Checkpoints a system prompt after load, so the first question does not wait for it (Qwen3.6, MiniCPM-V).
118
+ ...(typeof engine.prime === 'function' && { prime: (system) => engine.prime(system) }),
119
+ // Loads the vision tower, for an engine that reads images (MiniCPM-V).
120
+ ...(typeof engine.ensureVision === 'function' && { ensureVision: (o) => engine.ensureVision(o) }),
121
+ dispose: () => engine.dispose(),
122
+ };
123
+ },
124
+ };
@@ -0,0 +1,59 @@
1
+ // kohra: text-diffusion LMs (MDLM, BD3LM). kohra.js runs the denoising loop over onnxruntime-web; it loads from the
2
+ // CDN at load() time, at the commit src/sources.js pins; set kohraBackend.source to load another copy.
3
+
4
+ import { graphBytes } from './transformers.js';
5
+ import { SOURCES } from '../sources.js';
6
+
7
+ const FP16 = 'onnx/model_fp16_fused.onnx';
8
+ // The q4 graph decodes on WebGPU only with RTN packing and this ORT-web dev build (kohra README, "Known constraints").
9
+ const Q4 = 'onnx/model_q4f16_rtn_sym.onnx';
10
+ const Q4_ORT = '1.26.0-dev.20260416-b7804b056c';
11
+
12
+ const pickGraph = (model, caps) => {
13
+ const have = new Set(model.files.onnx.map((f) => f.name));
14
+ return caps.f16 && have.has(FP16) ? FP16 : have.has(Q4) ? Q4 : null;
15
+ };
16
+
17
+ // kohra's own template covers one user turn; this is the same ChatML over the whole conversation.
18
+ const chatml = (messages) =>
19
+ messages.map((m) => `<|im_start|>${m.role}\n${m.content}<|im_end|>\n`).join('') + '<|im_start|>assistant\n';
20
+
21
+ export const kohraBackend = {
22
+ id: 'kohra',
23
+ tasks: ['text-generation'],
24
+ source: SOURCES.kohra,
25
+
26
+ canRun(model, caps, { task } = {}) {
27
+ if (!model.tags?.includes('text-diffusion')) return { ok: false, code: 'model', reason: 'not a text-diffusion model' };
28
+ if (task && task !== 'text-generation') return { ok: false, code: 'task', reason: `task ${task}; kohra runs text-generation only` };
29
+ if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
30
+ const graph = pickGraph(model, caps);
31
+ if (!graph) return { ok: false, code: 'format', reason: `no kohra graph (${FP16} or ${Q4}) in the repo` };
32
+ const bytes = graphBytes(model, graph);
33
+ return { ok: true, estimate: { gpuBytes: bytes, diskBytes: bytes, tokPerSec: null } };
34
+ },
35
+
36
+ async load(model, { caps, onProgress, signal }) {
37
+ const graph = pickGraph(model, caps);
38
+ const { DiffusionLM } = await import(kohraBackend.source);
39
+ onProgress?.({ backend: 'kohra', status: 'loading', file: graph });
40
+ const base = `https://huggingface.co/${model.repo}/resolve/${model.revision || 'main'}`;
41
+ const lm = await DiffusionLM.from_pretrained({
42
+ model: `${base}/${graph}`, tokenizer: model.repo, ...(graph === Q4 && { ortVersion: Q4_ORT }),
43
+ });
44
+ signal?.throwIfAborted();
45
+ const blockCausal = model.tags.includes('block-diffusion');
46
+ return {
47
+ task: 'text-generation',
48
+ // Diffusion fills the whole block at once, so the reply arrives as one chunk.
49
+ async *generate(messages, { maxNewTokens = 128, steps, signal } = {}) {
50
+ signal?.throwIfAborted();
51
+ const out = await lm.generate(chatml(messages), {
52
+ maxNewTokens, steps: steps ?? maxNewTokens, blockCausal, chatTemplate: false, stripThink: true,
53
+ });
54
+ yield { text: out.text, tokens: out.tokens, tokPerSec: out.tokensPerSecond };
55
+ },
56
+ dispose: () => lm.session?.release?.(),
57
+ };
58
+ },
59
+ };
@@ -0,0 +1,116 @@
1
+ // LocalMind's hand-written WebGPU engines: whole model resident on the GPU, kernels written per architecture. Each
2
+ // engine reads its config from the model file, so it runs any checkpoint of its architecture in a format it reads,
3
+ // not only the one LocalMind ships. They live in diskformer (engines/, since v0.6.4) and load from the CDN at load()
4
+ // time, at the commit src/sources.js pins; set localmindBackend.source to load another copy. The id stays 'localmind',
5
+ // the name callers and stored() already use, though no code loads from LocalMind's repo any more.
6
+ //
7
+ // lfm2 src/lfm2_5.js GGUF, general.architecture 'lfm2' (dense LFM2 / LFM2.5), Q4_0 Q8_0 Q4_K Q5_K Q6_K
8
+ // gemma4 src/gemma-4-e2b.js Google's QAT-mobile safetensors export of Gemma 4 (E2B, E4B): config.json-driven
9
+ // qwen35 src/ternary_bonsai_2_27b.js GGUF, 'qwen35' with Prism's ternary types (PTQ1_0, PQ2_0) only: a standard
10
+ // K-quant (unsloth/Qwen3.5-4B-GGUF Q4_K_M) fails with "This feature is not included in this bundle".
11
+ // Speeds: LocalMind's quiet-machine engine bench (plan/bench-engines-quiet-2026-09-24.md, M4 Pro, 256 tokens, greedy).
12
+
13
+ import { SOURCES } from '../sources.js';
14
+
15
+ const ENGINES = {
16
+ lfm2: { file: 'lfm2_5.js', Engine: 'Lfm2Mobile' },
17
+ gemma4: { file: 'gemma-4-e2b.js', Engine: 'Gemma4Mobile' },
18
+ qwen35: { file: 'ternary_bonsai_2_27b.js', Engine: 'TernaryBonsai2' },
19
+ };
20
+
21
+ // Quant preference per engine, by the GGUF file-name suffix. Plain Q4_0 first: it is LocalMind's default.
22
+ const LFM2_QUANTS = ['Q4_0', 'Q4_K_M', 'Q4_K_S', 'Q5_K_M', 'Q5_K_S', 'Q6_K', 'Q8_0'];
23
+ const QWEN35_QUANTS = ['PTQ1_0', 'PQ2_0'];
24
+ const MEASURED = {
25
+ 'LiquidAI/LFM2.5-230M-GGUF': 1062.5,
26
+ 'google/gemma-4-E2B-it-qat-mobile-transformers': 171.3,
27
+ 'prism-ml/Ternary-Bonsai-2-27B-gguf': 27.2,
28
+ };
29
+
30
+ // The GGUF to load: the first quant in preference order. Projector (mmproj) files are vision towers, never the LM.
31
+ function pickGguf(model, quants) {
32
+ const lms = model.files.gguf.filter((f) => !/mmproj/i.test(f.name));
33
+ for (const q of quants) {
34
+ const f = lms.find((x) => new RegExp(`[-_.]${q}\\.gguf$`, 'i').test(x.name) && !/QAD/i.test(x.name));
35
+ if (f) return f;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ // Which engine reads this repo, and the file and bytes it would load; null when none does.
41
+ function match(model) {
42
+ if (model.formats.includes('gguf') && model.architecture === 'lfm2') {
43
+ const f = pickGguf(model, LFM2_QUANTS);
44
+ return f && { engine: 'lfm2', file: f.name, bytes: f.size };
45
+ }
46
+ if (model.formats.includes('gguf') && model.architecture === 'qwen35') {
47
+ const f = pickGguf(model, QWEN35_QUANTS);
48
+ return f && { engine: 'qwen35', file: f.name, bytes: f.size };
49
+ }
50
+ // The QAT-mobile export's tensor names (weight_scale, embedding_quantized …) are what the engine reads; a plain
51
+ // Gemma 4 safetensors checkpoint has model_type gemma4 too but will not load, so the repo name decides.
52
+ if (model.architecture === 'gemma4' && /-qat-mobile-transformers$/.test(model.repo)) {
53
+ const f = model.files.safetensors.find((x) => x.name === 'model.safetensors');
54
+ return f && { engine: 'gemma4', file: f.name, bytes: f.size };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ export const localmindBackend = {
60
+ id: 'localmind',
61
+ tasks: ['text-generation'],
62
+ source: SOURCES.diskformer,
63
+
64
+ canRun(model, caps, { budget, task }) {
65
+ const m = match(model);
66
+ if (!m) return { ok: false, code: 'model', reason: 'no LocalMind engine reads this repo (lfm2 GGUF, qwen35 Prism GGUF, Gemma 4 QAT-mobile)' };
67
+ if (task && task !== 'text-generation') return { ok: false, code: 'task', reason: `task ${task}; LocalMind's engines run text generation` };
68
+ if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
69
+ if (m.engine === 'qwen35' && !(caps.f16 && caps.subgroups)) return { ok: false, code: 'f16', reason: 'the ternary kernels need shader-f16 and subgroups' };
70
+ // The whole model stays on the GPU, so the budget must hold the file.
71
+ if (budget < m.bytes) return { ok: false, code: 'budget', need: m.bytes, reason: `${m.file} stays on the GPU whole (${(m.bytes / 1e9).toFixed(2)} GB) and the budget is ${(budget / 1e9).toFixed(2)} GB` };
72
+ return { ok: true, estimate: { gpuBytes: m.bytes, diskBytes: m.bytes, tokPerSec: MEASURED[model.repo] ?? null } };
73
+ },
74
+
75
+ async load(model, { maxCtx, onProgress, signal }) {
76
+ const m = match(model);
77
+ const e = ENGINES[m.engine];
78
+ const mod = await import(`${localmindBackend.source}/${e.file}`);
79
+ const opts = {
80
+ revision: model.revision || 'main', signal,
81
+ onProgress: onProgress && ((p) => onProgress({ backend: 'localmind', engine: m.engine, ...p })),
82
+ ...(m.engine !== 'gemma4' && { file: m.file }),
83
+ ...(maxCtx && m.engine !== 'gemma4' && { maxLength: maxCtx }),
84
+ };
85
+ const engine = await mod[e.Engine].load(model.repo, opts);
86
+ signal?.throwIfAborted();
87
+ // Thinking off, as the diskformer back end does: a chat reply, not the model's reasoning. (The qwen35 engine
88
+ // takes template arguments as a field; LocalMind sets the same one.)
89
+ if ('chatTemplateArgs' in engine) engine.chatTemplateArgs = { ...engine.chatTemplateArgs, enable_thinking: false };
90
+ // Compile the kernels now, as LocalMind's workers do, so the first reply is not paying for it.
91
+ onProgress?.({ backend: 'localmind', engine: m.engine, status: 'warmup' });
92
+ await engine.warmup?.();
93
+ // Gemma's template has no system turn; LocalMind folds the system prompt into the first user turn the same way.
94
+ const prepare = m.engine !== 'gemma4' ? (msgs) => msgs : (msgs) => {
95
+ const sys = msgs.filter((x) => x.role === 'system').map((x) => x.content).join('\n\n');
96
+ const rest = msgs.filter((x) => x.role !== 'system');
97
+ const i = rest.findIndex((x) => x.role === 'user');
98
+ return sys && i >= 0 ? rest.map((x, k) => (k === i ? { ...x, content: `${sys}\n\n${x.content}` } : x)) : rest;
99
+ };
100
+ let tokens = 0;
101
+ return {
102
+ task: 'text-generation',
103
+ engine,
104
+ async *generate(messages, { maxNewTokens = 512, signal } = {}) {
105
+ tokens = 0;
106
+ for await (const c of engine.generate(prepare(messages), { maxNewTokens, signal })) {
107
+ if (c.token != null) tokens++;
108
+ yield { text: c.text, tokens };
109
+ }
110
+ },
111
+ dispose: () => engine.dispose?.(),
112
+ };
113
+ },
114
+ };
115
+
116
+ export { match as localmindMatch };
@@ -0,0 +1,191 @@
1
+ // transformers.js: ONNX models on WebGPU, text generation plus the other pipeline tasks below. The library loads from the CDN at load() time, so
2
+ // importing anyformer costs nothing until this back end is chosen.
3
+
4
+
5
+ import { SOURCES } from '../sources.js';
6
+
7
+ // dtype → the ONNX file transformers.js reads for it (decoder-only repos keep one graph per dtype under onnx/).
8
+ const FILES = {
9
+ q4: 'onnx/model_q4.onnx', q2: 'onnx/model_q2.onnx', fp32: 'onnx/model.onnx',
10
+ q4f16: 'onnx/model_q4f16.onnx', q2f16: 'onnx/model_q2f16.onnx', fp16: 'onnx/model_fp16.onnx',
11
+ };
12
+
13
+ // f16 activations come last: on Apple Metal (Chrome 154, transformers.js 4.3.1) Qwen2.5-0.5B's q4f16 graph decodes
14
+ // to word salad and fp16 loops, while q4 (f32 activations) answers correctly (test/browser/transformers.html,
15
+ // 2026-10-08). kohra hit the same fp16 overflow on WebGPU (kohra README, "Known constraints").
16
+ function pickDtype(model, caps) {
17
+ const have = new Set(model.files.onnx.map((f) => f.name));
18
+ const order = caps.f16 ? ['q4', 'q2', 'fp32', 'q4f16', 'q2f16', 'fp16'] : ['q4', 'q2', 'fp32'];
19
+ return order.find((d) => have.has(FILES[d])) || null;
20
+ }
21
+
22
+ // Multimodal chat models: one ONNX graph per module (embed_tokens, decoder_model_merged, vision_encoder, audio_encoder)
23
+ // per precision, loaded through the model's conditional-generation class and its processor, as LocalMind does
24
+ // (LocalMind index.html loadMultimodal). Keyed by config.model_type. q4f16 first: LocalMind ships these at q4f16.
25
+ const MULTIMODAL = { gemma4: 'Gemma4ForConditionalGeneration', qwen3_5: 'Qwen3_5ForConditionalGeneration' };
26
+ const MM_SUFFIX = { fp32: '', fp16: '_fp16', q4: '_q4', q4f16: '_q4f16', q8: '_quantized' };
27
+ const mmModules = (model) => [...new Set(model.files.onnx.map((f) => /^onnx\/([a-z_]+?)(?:_fp16|_q4f16|_q4|_quantized)?\.onnx$/.exec(f.name)?.[1]).filter(Boolean))];
28
+ function pickMmDtype(model, caps) {
29
+ const have = new Set(model.files.onnx.map((f) => f.name));
30
+ const order = caps.f16 ? ['q4f16', 'q4', 'fp16', 'fp32'] : ['q4', 'fp32'];
31
+ return order.find((d) => have.has(`onnx/decoder_model_merged${MM_SUFFIX[d]}.onnx`)) || null;
32
+ }
33
+ const mmBytes = (model, dtype) => mmModules(model).reduce((n, m) => n + graphBytes(model, `onnx/${m}${MM_SUFFIX[dtype]}.onnx`), 0);
34
+ const isMultimodal = (model) => model.architecture in MULTIMODAL && model.files.onnx.some((f) => f.name.startsWith('onnx/decoder_model_merged'));
35
+ // The chat tasks a multimodal repo is tagged with on the Hub; all run here as chat.
36
+ const MM_TASKS = ['text-generation', 'image-text-to-text', 'any-to-any'];
37
+
38
+ // The graph plus its external-data files (<graph>.data, <graph>_data, <graph>_data_1 …).
39
+ export const graphBytes = (model, file) =>
40
+ model.files.all.filter((f) => f.name === file || f.name.startsWith(`${file}.data`) || f.name.startsWith(`${file}_data`))
41
+ .reduce((n, f) => n + (f.size || 0), 0);
42
+
43
+ // The non-generation tasks this back end runs, each through transformers.js's own pipeline(). They load the fp32
44
+ // graphs: the f16 paths gave wrong output on Metal for text generation, and these models are small.
45
+ const PIPELINES = {
46
+ 'feature-extraction': { options: { pooling: 'mean', normalize: true }, out: (t) => t.tolist() },
47
+ 'sentence-similarity': { task: 'feature-extraction', options: { pooling: 'mean', normalize: true }, out: (t) => t.tolist() },
48
+ 'automatic-speech-recognition': {},
49
+ 'object-detection': { image: true },
50
+ 'image-classification': { image: true },
51
+ 'depth-estimation': { image: true },
52
+ 'image-segmentation': { image: true },
53
+ 'token-classification': {},
54
+ 'text-classification': {},
55
+ };
56
+
57
+ // fp32 graphs: onnx/*.onnx without a precision suffix (model.onnx, encoder_model.onnx, decoder_model_merged.onnx).
58
+ const FP32 = /^onnx\/[a-z_]+(?<!_fp16|_q4|_q4f16|_quantized|_int8|_uint8|_bnb4|_q8)\.onnx$/;
59
+ const fp32Bytes = (model) => model.files.onnx.filter((f) => FP32.test(f.name)).reduce((n, f) => n + graphBytes(model, f.name), 0);
60
+
61
+ export const transformersBackend = {
62
+ id: 'transformers',
63
+ // The library, at the version src/sources.js pins. Set transformersBackend.source to load another version.
64
+ source: SOURCES.transformers,
65
+ tasks: ['text-generation', ...Object.keys(PIPELINES)],
66
+
67
+ canRun(model, caps, { task, budget = Infinity } = {}) {
68
+ const fits = (bytes, ok) => (bytes > budget
69
+ ? { ok: false, code: 'budget', need: bytes, reason: `the model stays on the GPU whole (${(bytes / 1e9).toFixed(2)} GB) and the budget is ${(budget / 1e9).toFixed(2)} GB` }
70
+ : ok);
71
+ if (!model.formats.includes('onnx')) return { ok: false, code: 'format', reason: 'no ONNX graph in the repo' };
72
+ // kohra's diffusion graphs are ONNX too, but transformers.js generate() is autoregressive only.
73
+ if (model.tags?.includes('text-diffusion')) return { ok: false, code: 'model', reason: 'a text-diffusion model; transformers.js generate() is autoregressive only' };
74
+ // A repo with no Hub task tag (e.g. onnx-community/Ternary-Bonsai-4B-ONNX) is taken as text generation.
75
+ if (isMultimodal(model)) {
76
+ if (task && !MM_TASKS.includes(task)) return { ok: false, code: 'task', reason: `task ${task}; ${model.architecture} runs here as chat` };
77
+ if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
78
+ const dtype = pickMmDtype(model, caps);
79
+ if (!dtype) return { ok: false, code: 'format', reason: 'no decoder_model_merged graph in a precision this back end loads' };
80
+ const bytes = mmBytes(model, dtype);
81
+ return fits(bytes, { ok: true, estimate: { gpuBytes: bytes, diskBytes: bytes, tokPerSec: null } });
82
+ }
83
+ const want = task || model.task || 'text-generation';
84
+ if (!transformersBackend.tasks.includes(want)) return { ok: false, code: 'task', reason: `task ${want || 'unknown'}; this back end runs ${transformersBackend.tasks.join(', ')}` };
85
+ if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
86
+ if (want !== 'text-generation') {
87
+ const bytes = fp32Bytes(model);
88
+ if (!bytes) return { ok: false, code: 'format', reason: 'no fp32 ONNX graph (onnx/model.onnx or encoder/decoder graphs)' };
89
+ return fits(bytes, { ok: true, estimate: { gpuBytes: bytes, diskBytes: bytes, tokPerSec: null } });
90
+ }
91
+ const dtype = pickDtype(model, caps);
92
+ if (!dtype) return { ok: false, code: 'format', reason: `no ONNX graph at ${Object.values(FILES).join(', ')}` };
93
+ const bytes = graphBytes(model, FILES[dtype]);
94
+ return fits(bytes, { ok: true, estimate: { gpuBytes: bytes, diskBytes: bytes, tokPerSec: null } });
95
+ },
96
+
97
+ // dtype overrides the precision pick (the repo must ship it). generate() takes repetitionPenalty; without it the
98
+ // model's own generation_config.json applies (Qwen2.5 ships 1.05), as in transformers.js itself.
99
+ async load(model, { task, caps, dtype: forced, onProgress, signal }) {
100
+ const want = task || model.task || 'text-generation';
101
+ const lib = await import(transformersBackend.source);
102
+ const progress_callback = onProgress && ((p) => onProgress({ backend: 'transformers', ...p }));
103
+ if (isMultimodal(model)) return loadMultimodal(lib, model, forced || pickMmDtype(model, caps), { progress_callback, signal });
104
+ if (want !== 'text-generation') return loadPipeline(lib, model, want, { progress_callback, signal });
105
+ const dtype = forced || pickDtype(model, caps);
106
+ const gen = await lib.pipeline('text-generation', model.repo, {
107
+ device: 'webgpu', dtype, revision: model.revision || 'main', progress_callback,
108
+ });
109
+ signal?.throwIfAborted();
110
+ return {
111
+ task: 'text-generation',
112
+ generate: (messages, { maxNewTokens = 256, repetitionPenalty, signal } = {}) => stream(lib, gen.tokenizer, signal,
113
+ (streamer, stopping_criteria) => gen(messages, { max_new_tokens: maxNewTokens, do_sample: false, ...(repetitionPenalty && { repetition_penalty: repetitionPenalty }), streamer, stopping_criteria })),
114
+ dispose: () => gen.dispose?.(),
115
+ };
116
+ },
117
+ };
118
+
119
+ // A non-generation task: one pipeline, run(input, opts) calls it. ImageData (from a canvas) becomes a RawImage; URLs,
120
+ // RawImage and, for speech, 16 kHz Float32Array samples pass through as transformers.js takes them.
121
+ async function loadPipeline(lib, model, task, { progress_callback, signal }) {
122
+ const spec = PIPELINES[task];
123
+ const pipe = await lib.pipeline(spec.task || task, model.repo, {
124
+ device: 'webgpu', dtype: 'fp32', revision: model.revision || 'main', progress_callback,
125
+ });
126
+ signal?.throwIfAborted();
127
+ const image = (x) => (typeof ImageData !== 'undefined' && x instanceof ImageData ? new lib.RawImage(x.data, x.width, x.height, 4) : x);
128
+ return {
129
+ task,
130
+ async run(input, opts = {}) {
131
+ const out = await pipe(spec.image ? image(input) : input, { ...spec.options, ...opts });
132
+ return spec.out ? spec.out(out) : out;
133
+ },
134
+ dispose: () => pipe.dispose?.(),
135
+ };
136
+ }
137
+
138
+ // transformers.js streams through a callback; this turns one generate call into the contract's async iterable of
139
+ // { text, tokens }, with the signal wired to an InterruptableStoppingCriteria.
140
+ async function* stream(lib, tokenizer, signal, run) {
141
+ const queue = [];
142
+ let wake = () => {}, done = false, failed = null, text = '', tokens = 0;
143
+ const streamer = new lib.TextStreamer(tokenizer, {
144
+ skip_prompt: true, skip_special_tokens: true,
145
+ callback_function: (t) => { text += t; queue.push({ text, tokens }); wake(); },
146
+ token_callback_function: () => { tokens++; },
147
+ });
148
+ const stop = new lib.InterruptableStoppingCriteria();
149
+ signal?.addEventListener('abort', () => stop.interrupt(), { once: true });
150
+ run(streamer, stop).catch((e) => { failed = e; }).finally(() => { done = true; wake(); });
151
+ while (true) {
152
+ if (queue.length) { yield queue.shift(); continue; }
153
+ if (done) break;
154
+ await new Promise((r) => { wake = r; });
155
+ }
156
+ if (failed) throw failed;
157
+ }
158
+
159
+ // A multimodal chat model. Message content is a string or an array of blocks: { type: 'text', text },
160
+ // { type: 'image', image } (ImageData, RawImage or a URL) and, for Gemma 4, { type: 'audio', audio } (16 kHz
161
+ // Float32Array). The template sees placeholders; the processor gets the media, in order.
162
+ async function loadMultimodal(lib, model, dtype, { progress_callback, signal }) {
163
+ const opts = { revision: model.revision || 'main', progress_callback };
164
+ const processor = await lib.AutoProcessor.from_pretrained(model.repo, opts);
165
+ const net = await lib[MULTIMODAL[model.architecture]].from_pretrained(model.repo, { ...opts, dtype, device: 'webgpu' });
166
+ signal?.throwIfAborted();
167
+ // One-token warmup compiles the kernels before the first reply (LocalMind does the same).
168
+ await net.generate({ ...(await processor('a', null, null, { add_special_tokens: false })), max_new_tokens: 1 });
169
+ const toImage = async (x) => (typeof ImageData !== 'undefined' && x instanceof ImageData ? new lib.RawImage(x.data, x.width, x.height, 4)
170
+ : typeof x === 'string' ? lib.load_image(x) : x);
171
+ return {
172
+ task: 'text-generation',
173
+ generate: (messages, { maxNewTokens = 256, repetitionPenalty, signal } = {}) => stream(lib, processor.tokenizer, signal, async (streamer, stopping_criteria) => {
174
+ const images = [], audios = [];
175
+ const plain = messages.map((m) => (typeof m.content === 'string' ? m : {
176
+ ...m,
177
+ content: m.content.map((b) => {
178
+ if (b.type === 'image') { images.push(b.image); return { type: 'image' }; }
179
+ if (b.type === 'audio') { audios.push(b.audio); return { type: 'audio' }; }
180
+ return b;
181
+ }),
182
+ }));
183
+ const prompt = processor.apply_chat_template(plain, { add_generation_prompt: true, enable_thinking: false });
184
+ const imgs = await Promise.all(images.map(toImage));
185
+ const one = (a) => (a.length ? (a.length === 1 ? a[0] : a) : null);
186
+ const inputs = await processor(prompt, one(imgs), one(audios), { add_special_tokens: false });
187
+ return net.generate({ ...inputs, max_new_tokens: maxNewTokens, do_sample: false, ...(repetitionPenalty && { repetition_penalty: repetitionPenalty }), streamer, stopping_criteria });
188
+ }),
189
+ dispose: () => net.dispose?.(),
190
+ };
191
+ }
@@ -0,0 +1,122 @@
1
+ // wllama: llama.cpp compiled to WebAssembly, for GGUF models no hand-written engine covers (any architecture its
2
+ // llama.cpp knows, e.g. openbmb/MiniCPM5-2B-GGUF). It offloads every layer to WebGPU when the browser has it and runs
3
+ // on the CPU otherwise, so it is anyformer's one path that needs no WebGPU. Listed last in BACKENDS: an engine written
4
+ // for the architecture (localmind, diskformer) wins when one exists.
5
+
6
+ import { SOURCES } from '../sources.js';
7
+ import { splitToOpfs } from '../gguf-split.js';
8
+
9
+ // wllama reads files of at most 2 GB (one ArrayBuffer each) into a 32-bit WebAssembly memory of 4 GB. A model over
10
+ // 2 GB loads as shards: the repo's own split set when it has one, else shards anyformer writes to OPFS
11
+ // (src/gguf-split.js). The whole model, shards included, must stay under TOTAL_LIMIT to leave room for the context.
12
+ const FILE_LIMIT = 2 ** 31;
13
+ const TOTAL_LIMIT = 3.6e9;
14
+ const QUANTS = ['Q4_K_M', 'Q4_0', 'Q4_K_S', 'Q5_K_M', 'Q5_K_S', 'Q6_K', 'Q8_0'];
15
+ const SPLIT = /-(\d{5})-of-(\d{5})\.gguf$/i;
16
+
17
+ // What to load: { kind: 'file' | 'split' | 'local-split', name, files: [{ name, size }], bytes }, or null.
18
+ function pickFile(model) {
19
+ const lms = model.files.gguf.filter((f) => !/mmproj/i.test(f.name));
20
+ const sets = new Map();
21
+ for (const f of lms.filter((x) => SPLIT.test(x.name))) {
22
+ const base = f.name.replace(SPLIT, '');
23
+ sets.set(base, [...(sets.get(base) || []), f]);
24
+ }
25
+ for (const q of QUANTS) {
26
+ const quant = new RegExp(`[-_.]${q}$`, 'i');
27
+ const single = lms.find((x) => !SPLIT.test(x.name) && quant.test(x.name.replace(/\.gguf$/i, '')));
28
+ if (single) return { kind: single.size > FILE_LIMIT ? 'local-split' : 'file', name: single.name, files: [single], bytes: single.size };
29
+ for (const [base, parts] of sets) {
30
+ const total = Number(SPLIT.exec(parts[0].name)[2]);
31
+ if (quant.test(base) && parts.length === total) {
32
+ const files = [...parts].sort((a, b) => a.name.localeCompare(b.name));
33
+ return { kind: 'split', name: files[0].name, files, bytes: files.reduce((n, f) => n + f.size, 0) };
34
+ }
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ // wllama 3.4.1's stream ends one result early: the last chunk stays queued in the wasm side and would open the next
41
+ // request. Read the queue dry before and after every reply, as LocalMind does (its WLLAMA-DRAIN, index.html).
42
+ async function drain(w) {
43
+ const proxy = w?.proxy;
44
+ if (!proxy?.wllamaAction) return [];
45
+ const out = [];
46
+ for (let i = 0; i < 64; i++) {
47
+ const r = await proxy.wllamaAction('get_result', { _name: 'gres_req' });
48
+ const j = r?.data_json;
49
+ if (!j || j === 'null') { if (!r?.has_more) break; continue; }
50
+ let arr; try { arr = JSON.parse(j); } catch { break; }
51
+ out.push(...(Array.isArray(arr) ? arr : [arr]));
52
+ }
53
+ return out;
54
+ }
55
+ const delta = (chunk) => chunk?.choices?.[0]?.delta?.content || '';
56
+
57
+ export const wllamaBackend = {
58
+ id: 'wllama',
59
+ tasks: ['text-generation'],
60
+ source: SOURCES.wllama,
61
+
62
+ canRun(model, caps, { budget, task } = {}) {
63
+ if (!model.formats.includes('gguf')) return { ok: false, code: 'format', reason: 'no GGUF in the repo' };
64
+ if (task && task !== 'text-generation' && task !== 'image-text-to-text') return { ok: false, code: 'task', reason: `task ${task}; wllama runs text generation` };
65
+ const f = pickFile(model);
66
+ if (!f) return { ok: false, code: 'model', reason: `no GGUF at ${QUANTS.join(', ')}` };
67
+ if (f.bytes > TOTAL_LIMIT) return { ok: false, code: 'model', reason: `${f.name} is ${(f.bytes / 1e9).toFixed(2)} GB; wllama's 32-bit WebAssembly memory holds about ${TOTAL_LIMIT / 1e9} GB of model` };
68
+ if (f.kind === 'local-split' && !caps.opfs) return { ok: false, code: 'opfs', reason: `${f.name} is over 2 GB and is split into OPFS before loading` };
69
+ // On WebGPU the whole model sits on the GPU, so it must fit the budget; without WebGPU it runs on the CPU.
70
+ const gpu = caps.webgpu && f.bytes <= budget;
71
+ return { ok: true, estimate: { gpuBytes: gpu ? f.bytes : 0, diskBytes: f.bytes, tokPerSec: null } };
72
+ },
73
+
74
+ async load(model, { caps, budget, maxCtx = 4096, onProgress, signal }) {
75
+ const f = pickFile(model);
76
+ const { Wllama } = await import(`${wllamaBackend.source}/index.js`);
77
+ const w = new Wllama({ default: `${wllamaBackend.source}/wasm/wllama.wasm` }, { logger: { debug() {}, log() {}, warn() {}, error: (m) => console.error('[wllama]', m) } });
78
+ // isSupportWebGPU() may answer directly or with a promise, depending on the wllama version.
79
+ let gpu = false;
80
+ if (caps.webgpu && f.bytes <= budget) { try { gpu = !!(await w.isSupportWebGPU()); } catch { gpu = false; } }
81
+ const rev = model.revision || 'main';
82
+ const url = (name) => `https://huggingface.co/${model.repo}/resolve/${rev}/${name}`;
83
+ const cfg = {
84
+ n_ctx: maxCtx, n_gpu_layers: gpu ? 999 : 0,
85
+ progressCallback: onProgress && (({ loaded, total }) => onProgress({ backend: 'wllama', status: 'download', file: f.name, loaded, total })),
86
+ };
87
+ if (f.kind === 'local-split') {
88
+ const shards = await splitToOpfs({
89
+ url: url(f.name), size: f.bytes, repo: model.repo, file: f.name, signal,
90
+ key: `${model.repo}--${rev}--${f.name}`.replace(/[^A-Za-z0-9._-]+/g, '-'),
91
+ onProgress: onProgress && ((p) => onProgress({ backend: 'wllama', ...p })),
92
+ });
93
+ await w.loadModel(shards, cfg);
94
+ } else {
95
+ await w.loadModelFromUrl(f.kind === 'split' ? f.files.map((x) => url(x.name)) : url(f.name), cfg);
96
+ }
97
+ signal?.throwIfAborted();
98
+ return {
99
+ task: 'text-generation',
100
+ device: gpu ? 'webgpu' : 'cpu',
101
+ async *generate(messages, { maxNewTokens = 256, signal } = {}) {
102
+ const msgs = messages.map((m) => ({ role: m.role, content: typeof m.content === 'string' ? m.content : m.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n') }));
103
+ await drain(w).catch(() => {});
104
+ const ctl = new AbortController();
105
+ signal?.addEventListener('abort', () => ctl.abort(), { once: true });
106
+ let text = '', tokens = 0;
107
+ try {
108
+ // Thinking off, as on every other back end: MiniCPM5 otherwise spends the reply in reasoning_content.
109
+ const stream = await w.createChatCompletion({
110
+ messages: msgs, stream: true, max_tokens: maxNewTokens, temperature: 0, abortSignal: ctl.signal,
111
+ chat_template_kwargs: { enable_thinking: false },
112
+ });
113
+ for await (const c of stream) { const d = delta(c); if (d) { text += d; tokens++; yield { text, tokens }; } }
114
+ for (const c of await drain(w)) { const d = delta(c); if (d) { text += d; tokens++; yield { text, tokens }; } }
115
+ } catch (e) {
116
+ if (!ctl.signal.aborted) throw e;
117
+ }
118
+ },
119
+ dispose: () => w.exit(),
120
+ };
121
+ },
122
+ };