anyformer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ };
@@ -0,0 +1,34 @@
1
+ // A model descriptor from a Hugging Face repo id: which weight formats the repo holds, their files and sizes, and the
2
+ // architecture. Back ends decide from this alone; describe() never decides who runs a model.
3
+
4
+ const HF = 'https://huggingface.co';
5
+
6
+ export async function describe(repo, { fetch = globalThis.fetch, revision = 'main', endpoint = HF } = {}) {
7
+ const url = `${endpoint}/api/models/${repo}${revision === 'main' ? '' : `/revision/${revision}`}?blobs=true`;
8
+ const res = await fetch(url);
9
+ if (!res.ok) throw new Error(`describe ${repo}: ${url} returned ${res.status}`);
10
+ return fromHubInfo(await res.json(), { endpoint });
11
+ }
12
+
13
+ // Pure: Hub API JSON → descriptor. Exported for tests and for callers that already hold the JSON.
14
+ export function fromHubInfo(info, { endpoint = HF } = {}) {
15
+ const files = (info.siblings || []).map((s) => ({ name: s.rfilename, size: s.size ?? null }));
16
+ const gguf = files.filter((f) => f.name.endsWith('.gguf'));
17
+ const onnx = files.filter((f) => f.name.endsWith('.onnx'));
18
+ const safetensors = files.filter((f) => f.name.endsWith('.safetensors'));
19
+ const formats = [gguf.length && 'gguf', onnx.length && 'onnx', safetensors.length && 'safetensors'].filter(Boolean);
20
+ return {
21
+ repo: info.id,
22
+ revision: info.sha || null,
23
+ endpoint,
24
+ formats,
25
+ // GGUF repos report the architecture in the gguf block; transformers repos in config.model_type.
26
+ architecture: info.gguf?.architecture || info.config?.model_type || null,
27
+ task: info.pipeline_tag || null,
28
+ // Hub tags carry what format and architecture miss, e.g. 'text-diffusion' marks a kohra model.
29
+ tags: info.tags || [],
30
+ // all: every file, for the side files a format splits off (ONNX external data, tokenizers).
31
+ files: { gguf, onnx, safetensors, all: files },
32
+ ggufTotalBytes: info.gguf?.total ?? null,
33
+ };
34
+ }
package/src/errors.js ADDED
@@ -0,0 +1,36 @@
1
+ // Every error load() and a session throw is an AnyformerError with a code from ERROR_CODES. Callers branch on the
2
+ // code; the message is for people and can change. (Back-end refusals before loading are REFUSALS in resolve.js.)
3
+
4
+ export const ERROR_CODES = {
5
+ NO_BACKEND: 'no back end can run the model under this budget; err.plan.candidates holds each code and reason',
6
+ LOAD_FAILED: 'the repo lookup, a download or the back end failed while loading; err.cause holds the original error',
7
+ BUDGET_TOO_SMALL: 'the back end measured the model at load time and the budget does not cover it; retry with err.need',
8
+ ABORTED: 'the AbortSignal fired; err.cause holds its reason',
9
+ EXECUTION_FAILED: 'the back end failed while generating; err.cause holds the original error',
10
+ INVALID_ARGUMENT: 'a value passed by the caller is wrong',
11
+ DISPOSED: 'the session was disposed; load the model again',
12
+ };
13
+
14
+ export class AnyformerError extends Error {
15
+ constructor(code, message, { cause, ...extra } = {}) {
16
+ super(message, cause === undefined ? undefined : { cause });
17
+ this.code = code;
18
+ Object.assign(this, extra);
19
+ }
20
+ }
21
+ // On the prototype, so stack traces say AnyformerError.
22
+ AnyformerError.prototype.name = 'AnyformerError';
23
+
24
+ // By name and code, not instanceof: a page can hold two copies of anyformer (CDN imports), and the check still holds.
25
+ export function isAnyformerError(e, code) {
26
+ return e instanceof Error && e.name === 'AnyformerError' && e.code in ERROR_CODES && (code === undefined || e.code === code);
27
+ }
28
+
29
+ // Any error into an AnyformerError. An aborted signal wins: fetch rejects with the abort reason, which is not always
30
+ // an AbortError. An AnyformerError keeps its code; anything else gets `code` (the step that failed).
31
+ export function wrap(e, code, signal, where = '') {
32
+ if (signal?.aborted) return isAnyformerError(e, 'ABORTED') ? e : new AnyformerError('ABORTED', `${where}aborted`, { cause: signal.reason });
33
+ if (isAnyformerError(e)) return e;
34
+ if (e?.name === 'AbortError') return new AnyformerError('ABORTED', `${where}aborted`, { cause: e });
35
+ return new AnyformerError(code, `${where}${e?.message ?? e}`, { cause: e });
36
+ }
@@ -0,0 +1,160 @@
1
+ // A GGUF too big for one ArrayBuffer (wllama reads files of at most 2 GB) becomes shards in llama.cpp's gguf-split
2
+ // layout, the same files `llama-gguf-split --split-max-size` writes: shard 1 carries every metadata key, each shard
3
+ // carries split.no / split.count / split.tensors.count and its own tensor table, and each shard's data section holds
4
+ // its tensors with offsets relative to it. Tensor bytes are copied, never decoded, so any quant type works.
5
+
6
+ const MAGIC = 0x46554747; // 'GGUF'
7
+ const DEFAULT_ALIGNMENT = 32;
8
+ // GGUF metadata value types → byte size (fixed-size types only; 8 = string, 9 = array).
9
+ const SIZE = { 0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8 };
10
+
11
+ class Short extends Error {} // the header runs past the bytes read so far
12
+
13
+ // Parse a GGUF header from its first bytes. Returns the raw metadata bytes (copied verbatim into shard 1), the tensor
14
+ // table, the alignment and where the data section starts. Throws Short when `u8` stops inside the header.
15
+ export function parseHeader(u8) {
16
+ const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
17
+ let p = 0;
18
+ const need = (n) => { if (p + n > u8.byteLength) throw new Short(); };
19
+ const u32 = () => { need(4); const v = dv.getUint32(p, true); p += 4; return v; };
20
+ const u64 = () => { need(8); const v = dv.getBigUint64(p, true); p += 8; return Number(v); };
21
+ const str = () => { const n = u64(); need(n); const s = new TextDecoder().decode(u8.subarray(p, p + n)); p += n; return s; };
22
+ const skip = (type) => {
23
+ if (type === 8) { const n = u64(); need(n); p += n; return; }
24
+ if (type === 9) { const t = u32(), n = u64(); if (t in SIZE) { need(n * SIZE[t]); p += n * SIZE[t]; } else for (let i = 0; i < n; i++) skip(t); return; }
25
+ need(SIZE[type]); p += SIZE[type];
26
+ };
27
+ if (u32() !== MAGIC) throw new Error('not a GGUF file');
28
+ const version = u32();
29
+ if (version < 2) throw new Error(`GGUF version ${version} is not supported`);
30
+ const tensorCount = u64(), kvCount = u64();
31
+ const kvStart = p;
32
+ let alignment = DEFAULT_ALIGNMENT;
33
+ for (let i = 0; i < kvCount; i++) {
34
+ const key = str(), type = u32();
35
+ if (key === 'general.alignment' && type === 4) { need(4); alignment = dv.getUint32(p, true); }
36
+ if (key.startsWith('split.')) throw new Error(`already a shard (${key})`);
37
+ skip(type);
38
+ }
39
+ const kvBytes = u8.slice(kvStart, p);
40
+ const tensors = [];
41
+ for (let i = 0; i < tensorCount; i++) {
42
+ const name = str(), nDims = u32(), dims = [];
43
+ for (let d = 0; d < nDims; d++) dims.push(u64());
44
+ tensors.push({ name, dims, type: u32(), offset: u64() });
45
+ }
46
+ const dataStart = Math.ceil(p / alignment) * alignment;
47
+ return { version, kvCount, kvBytes, tensors, alignment, dataStart };
48
+ }
49
+
50
+ // Read the header through `read(start, end)` (bytes [start, end)), widening the window until it parses.
51
+ export async function readHeader(read, fileSize) {
52
+ for (let n = 8 << 20; ; n *= 2) {
53
+ try { return parseHeader(await read(0, Math.min(n, fileSize))); } catch (e) {
54
+ if (!(e instanceof Short) || n >= fileSize) throw e;
55
+ }
56
+ }
57
+ }
58
+
59
+ // Group tensors (in data order) into shards of at most maxBytes of data each. A tensor's bytes run to the next
60
+ // tensor's offset, so the original alignment padding travels with it and every copied offset stays aligned.
61
+ export function planShards(header, fileSize, maxBytes) {
62
+ const t = [...header.tensors].sort((a, b) => a.offset - b.offset);
63
+ const dataSize = fileSize - header.dataStart;
64
+ const sized = t.map((x, i) => ({ ...x, bytes: (i + 1 < t.length ? t[i + 1].offset : dataSize) - x.offset }));
65
+ const shards = [];
66
+ let cur = null;
67
+ for (const x of sized) {
68
+ if (!cur || (cur.bytes + x.bytes > maxBytes && cur.tensors.length)) shards.push(cur = { tensors: [], bytes: 0 });
69
+ cur.tensors.push({ ...x, newOffset: cur.bytes });
70
+ cur.bytes += x.bytes;
71
+ }
72
+ for (const s of shards) { s.start = header.dataStart + s.tensors[0].offset; s.end = s.start + s.bytes; }
73
+ return shards;
74
+ }
75
+
76
+ // The header bytes of shard i of n: magic, version, counts, metadata (all of it in shard 0, split keys only in the
77
+ // rest), this shard's tensor table, padding to the alignment.
78
+ export function shardHeader(header, shards, i) {
79
+ const out = [];
80
+ const enc = new TextEncoder();
81
+ const u32 = (v) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, v, true); out.push(b); };
82
+ const u64 = (v) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(v), true); out.push(b); };
83
+ const u16 = (v) => { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, v, true); out.push(b); };
84
+ const i32 = (v) => { const b = new Uint8Array(4); new DataView(b.buffer).setInt32(0, v, true); out.push(b); };
85
+ const str = (s) => { const b = enc.encode(s); u64(b.length); out.push(b); };
86
+ const shard = shards[i];
87
+ u32(MAGIC); u32(header.version);
88
+ u64(shard.tensors.length);
89
+ u64((i === 0 ? header.kvCount : 0) + 3);
90
+ if (i === 0) out.push(header.kvBytes);
91
+ str('split.no'); u32(2); u16(i);
92
+ str('split.count'); u32(2); u16(shards.length);
93
+ str('split.tensors.count'); u32(5); i32(header.tensors.length);
94
+ for (const t of shard.tensors) {
95
+ str(t.name); u32(t.dims.length);
96
+ for (const d of t.dims) u64(d);
97
+ u32(t.type); u64(t.newOffset);
98
+ }
99
+ // Shards after the first carry no general.alignment, so llama.cpp reads them at the default 32.
100
+ const align = i === 0 ? header.alignment : DEFAULT_ALIGNMENT;
101
+ const len = out.reduce((n, b) => n + b.length, 0);
102
+ out.push(new Uint8Array(Math.ceil(len / align) * align - len));
103
+ const all = new Uint8Array(out.reduce((n, b) => n + b.length, 0));
104
+ let p = 0;
105
+ for (const b of out) { all.set(b, p); p += b.length; }
106
+ return all;
107
+ }
108
+
109
+ export const shardName = (base, i, n) => `${base}-${String(i + 1).padStart(5, '0')}-of-${String(n).padStart(5, '0')}.gguf`;
110
+
111
+ // Split the GGUF at `url` into shards of at most maxBytes in OPFS (anyformer-gguf/<key>/), reading it with HTTP range
112
+ // requests, and return the shards as Files in order. Crash-safe and resumable: a shard of the expected size is kept,
113
+ // anything else is rewritten, and done.json is written last; with done.json present nothing is fetched.
114
+ export const SPLIT_ROOT = 'anyformer-gguf';
115
+ export async function splitToOpfs({ url, size, key, repo, file, maxBytes = 512 << 20, onProgress, signal, fetch: f = globalThis.fetch }) {
116
+ const root = await (await navigator.storage.getDirectory()).getDirectoryHandle(SPLIT_ROOT, { create: true });
117
+ const dir = await root.getDirectoryHandle(key, { create: true });
118
+ const files = async (names) => Promise.all(names.map(async (n) => (await dir.getFileHandle(n)).getFile()));
119
+ // meta.json first, so an interrupted split still names its repo (src/store.js lists and removes it).
120
+ const meta = await (await dir.getFileHandle('meta.json', { create: true })).createWritable();
121
+ await meta.write(JSON.stringify({ repo, file, url, size }));
122
+ await meta.close();
123
+ try {
124
+ const done = JSON.parse(await (await (await dir.getFileHandle('done.json')).getFile()).text());
125
+ if (done.url === url && done.size === size) return files(done.shards);
126
+ } catch { /* not split yet, or interrupted */ }
127
+ const range = async (s, e) => {
128
+ const r = await f(url, { headers: { Range: `bytes=${s}-${e - 1}` }, signal });
129
+ if (r.status !== 206 && !(r.ok && s === 0 && e >= size)) throw new Error(`${url}: range request returned ${r.status}`);
130
+ return r;
131
+ };
132
+ const header = await readHeader(async (s, e) => new Uint8Array(await (await range(s, e)).arrayBuffer()), size);
133
+ const shards = planShards(header, size, maxBytes);
134
+ const base = file.replace(/\.gguf$/i, '');
135
+ const names = shards.map((_, i) => shardName(base, i, shards.length));
136
+ let copied = 0;
137
+ for (const [i, s] of shards.entries()) {
138
+ const head = shardHeader(header, shards, i);
139
+ const expect = head.length + s.bytes;
140
+ const h = await dir.getFileHandle(names[i], { create: true });
141
+ if ((await h.getFile()).size === expect) { copied += s.bytes; continue; }
142
+ const w = await h.createWritable(); // commits atomically on close(); an abort leaves the old file
143
+ try {
144
+ await w.write(head);
145
+ const body = (await range(s.start, s.end)).body.getReader();
146
+ for (;;) {
147
+ const { done, value } = await body.read();
148
+ if (done) break;
149
+ await w.write(value);
150
+ copied += value.length;
151
+ onProgress?.({ status: 'split', file: names[i], loaded: copied, total: size - header.dataStart });
152
+ }
153
+ await w.close();
154
+ } catch (e) { await w.abort().catch(() => {}); throw e; }
155
+ }
156
+ const marker = await (await dir.getFileHandle('done.json', { create: true })).createWritable();
157
+ await marker.write(JSON.stringify({ url, size, repo, file, shards: names }));
158
+ await marker.close();
159
+ return files(names);
160
+ }
package/src/load.js ADDED
@@ -0,0 +1,106 @@
1
+ import { probe } from './probe.js';
2
+ import { describe } from './describe.js';
3
+ import { resolve } from './resolve.js';
4
+ import { transformersBackend } from './backends/transformers.js';
5
+ import { kohraBackend } from './backends/kohra.js';
6
+ import { diskformerBackend } from './backends/diskformer.js';
7
+ import { localmindBackend } from './backends/localmind.js';
8
+ import { wllamaBackend } from './backends/wllama.js';
9
+ import { AnyformerError, wrap } from './errors.js';
10
+
11
+ // Back ends land here as each one passes its browser test (test/browser/). The list order breaks ties.
12
+ export const BACKENDS = [transformersBackend, kohraBackend, diskformerBackend, localmindBackend, wllamaBackend];
13
+
14
+ // 2.5 GB: the smallest budget diskformer's chat gate passes at. Browsers do not report GPU memory, so a caller that
15
+ // knows more passes { budget }.
16
+ export const DEFAULT_BUDGET = 2.5e9;
17
+
18
+ function noBackend(plan) {
19
+ const lines = plan.candidates.map((c) => ` ${c.backend} [${c.code}]: ${c.reason}`);
20
+ return new AnyformerError('NO_BACKEND', `no back end can run ${plan.model.repo} at a ${(plan.budget / 1e9).toFixed(1)} GB budget` +
21
+ (lines.length ? `:\n${lines.join('\n')}` : ': no back ends registered'), { plan });
22
+ }
23
+
24
+ // What load() would do, without loading: the descriptor, the caps, and every back end's answer.
25
+ // task: a Hugging Face pipeline task ('text-generation', 'feature-extraction', …); null runs the repo's own.
26
+ // repo: a Hub id, or an entry from MODELS (its task and budget apply unless opts set them).
27
+ const entry = (repo, opts) => (typeof repo === 'object' && repo?.repo
28
+ ? [repo.repo, { ...(repo.task && { task: repo.task }), ...(repo.budget && { budget: repo.budget }), ...opts }]
29
+ : [repo, opts]);
30
+
31
+ export async function plan(repoOrEntry, opts = {}) {
32
+ let [repo, { budget = DEFAULT_BUDGET, task = null, backends = BACKENDS, caps, model, fetch }] = entry(repoOrEntry, opts);
33
+ caps ??= await probe();
34
+ model ??= await describe(repo, fetch ? { fetch } : {});
35
+ return { model, caps, budget, task, ...(await resolve(model, caps, backends, { budget, task })) };
36
+ }
37
+
38
+ export async function load(repoOrEntry, options = {}) {
39
+ const [repo, opts] = entry(repoOrEntry, options);
40
+ const { backends = BACKENDS, maxCtx, dtype, onProgress, signal } = opts;
41
+ let p;
42
+ try { p = await plan(repo, opts); } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${repo}: `); }
43
+ if (!p.chosen) throw noBackend(p);
44
+ const backend = backends.find((b) => b.id === p.chosen);
45
+ let session;
46
+ try {
47
+ session = await backend.load(p.model, { budget: p.budget, task: p.task, caps: p.caps, maxCtx, dtype, onProgress, signal });
48
+ } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${backend.id}: `); }
49
+ return { backend: backend.id, task: session.task || p.task || p.model.task || null, ...(session.device && { device: session.device }), plan: p, ...serialize(session) };
50
+ }
51
+
52
+ // content: a string, or an array of { type, … } blocks (text, image, audio) for multimodal models.
53
+ const validContent = (c) => typeof c === 'string' || (Array.isArray(c) && c.every((b) => typeof b?.type === 'string'));
54
+ const validMessages = (messages) => Array.isArray(messages) && messages.every((m) => typeof m?.role === 'string' && validContent(m?.content));
55
+
56
+ // One call at a time per session: two overlapping generates would share one KV cache. A call waits for the previous
57
+ // one to finish (or for its caller to stop iterating). dispose() waits for the running call, then frees the GPU;
58
+ // calls after it throw DISPOSED.
59
+ function serialize(session) {
60
+ let tail = Promise.resolve(), disposed = false;
61
+ const gone = () => new AnyformerError('DISPOSED', 'this session was disposed; load the model again');
62
+ const turn = async () => {
63
+ let release;
64
+ const mine = new Promise((r) => { release = r; });
65
+ const prev = tail;
66
+ tail = prev.then(() => mine);
67
+ await prev;
68
+ return release;
69
+ };
70
+ const generate = session.generate && async function* (messages, o = {}) {
71
+ if (disposed) throw gone();
72
+ if (!validMessages(messages)) throw new AnyformerError('INVALID_ARGUMENT', 'messages must be an array of { role, content }, content a string or an array of { type } blocks');
73
+ const release = await turn();
74
+ try {
75
+ if (disposed) throw gone();
76
+ for await (const chunk of session.generate(messages, o)) yield chunk;
77
+ } catch (e) {
78
+ throw wrap(e, 'EXECUTION_FAILED', o.signal);
79
+ } finally {
80
+ release();
81
+ }
82
+ };
83
+ return {
84
+ ...(generate && { generate }),
85
+ // The one call every task has. For text generation it is the last chunk of generate().
86
+ async run(input, o = {}) {
87
+ if (generate) { let last; for await (const c of generate(input, o)) last = c; return last; }
88
+ if (disposed) throw gone();
89
+ const release = await turn();
90
+ try {
91
+ if (disposed) throw gone();
92
+ return await session.run(input, o);
93
+ } catch (e) {
94
+ throw wrap(e, 'EXECUTION_FAILED', o.signal);
95
+ } finally {
96
+ release();
97
+ }
98
+ },
99
+ async dispose() {
100
+ if (disposed) return;
101
+ disposed = true;
102
+ await tail;
103
+ await session.dispose?.();
104
+ },
105
+ };
106
+ }