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,47 @@
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
+ // The last answer the Hub gave for each URL, kept in Cache Storage so a model already downloaded still loads offline.
7
+ export const DESCRIBE_CACHE = 'anyformer-describe-v1';
8
+
9
+ export async function describe(repo, { fetch = globalThis.fetch, revision = 'main', endpoint = HF, caches = globalThis.caches } = {}) {
10
+ const url = `${endpoint}/api/models/${repo}${revision === 'main' ? '' : `/revision/${revision}`}?blobs=true`;
11
+ const store = caches ? await caches.open(DESCRIBE_CACHE).catch(() => null) : null;
12
+ let res, failed;
13
+ try { res = await fetch(url); } catch (e) { failed = e; }
14
+ if (res?.ok) {
15
+ const info = await res.json();
16
+ await store?.put(url, new Response(JSON.stringify(info), { headers: { 'Content-Type': 'application/json' } })).catch(() => {});
17
+ return fromHubInfo(info, { endpoint });
18
+ }
19
+ // No network, or the Hub failed: the cached answer, when this browser has one.
20
+ const hit = await store?.match(url).catch(() => null);
21
+ if (hit) return fromHubInfo(await hit.json(), { endpoint });
22
+ throw failed ? new Error(`describe ${repo}: ${url} failed: ${failed.message}`, { cause: failed })
23
+ : new Error(`describe ${repo}: ${url} returned ${res.status}`);
24
+ }
25
+
26
+ // Pure: Hub API JSON → descriptor. Exported for tests and for callers that already hold the JSON.
27
+ export function fromHubInfo(info, { endpoint = HF } = {}) {
28
+ const files = (info.siblings || []).map((s) => ({ name: s.rfilename, size: s.size ?? null }));
29
+ const gguf = files.filter((f) => f.name.endsWith('.gguf'));
30
+ const onnx = files.filter((f) => f.name.endsWith('.onnx'));
31
+ const safetensors = files.filter((f) => f.name.endsWith('.safetensors'));
32
+ const formats = [gguf.length && 'gguf', onnx.length && 'onnx', safetensors.length && 'safetensors'].filter(Boolean);
33
+ return {
34
+ repo: info.id,
35
+ revision: info.sha || null,
36
+ endpoint,
37
+ formats,
38
+ // GGUF repos report the architecture in the gguf block; transformers repos in config.model_type.
39
+ architecture: info.gguf?.architecture || info.config?.model_type || null,
40
+ task: info.pipeline_tag || null,
41
+ // Hub tags carry what format and architecture miss, e.g. 'text-diffusion' marks a kohra model.
42
+ tags: info.tags || [],
43
+ // all: every file, for the side files a format splits off (ONNX external data, tokenizers).
44
+ files: { gguf, onnx, safetensors, all: files },
45
+ ggufTotalBytes: info.gguf?.total ?? null,
46
+ };
47
+ }
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,127 @@
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 { diskformerLlamaBackend } from './backends/diskformer-llama.js';
8
+ import { localmindBackend } from './backends/localmind.js';
9
+ import { wllamaBackend } from './backends/wllama.js';
10
+ import { AnyformerError, wrap } from './errors.js';
11
+
12
+ // Back ends land here as each one passes its browser test (test/browser/). The list order breaks ties: a
13
+ // diskformer hand engine carries a measured speed and outranks diskformer-llama for the same model; diskformer-llama
14
+ // comes before wllama because it pages experts instead of holding the whole model on the GPU.
15
+ export const BACKENDS = [transformersBackend, kohraBackend, diskformerBackend, localmindBackend, diskformerLlamaBackend, wllamaBackend];
16
+
17
+ // 2.5 GB: the smallest budget diskformer's chat gate passes at. Browsers do not report GPU memory, so a caller that
18
+ // knows more passes { budget }.
19
+ export const DEFAULT_BUDGET = 2.5e9;
20
+
21
+ function noBackend(plan) {
22
+ const lines = plan.candidates.map((c) => ` ${c.backend} [${c.code}]: ${c.reason}`);
23
+ return new AnyformerError('NO_BACKEND', `no back end can run ${plan.model.repo} at a ${(plan.budget / 1e9).toFixed(1)} GB budget` +
24
+ (lines.length ? `:\n${lines.join('\n')}` : ': no back ends registered'), { plan });
25
+ }
26
+
27
+ // What load() would do, without loading: the descriptor, the caps, and every back end's answer.
28
+ // task: a Hugging Face pipeline task ('text-generation', 'feature-extraction', …); null runs the repo's own.
29
+ // file: a weights file in the repo to load (a GGUF name); null lets each back end pick.
30
+ // repo: a Hub id, or an entry from MODELS (its task and budget apply unless opts set them).
31
+ const entry = (repo, opts) => (typeof repo === 'object' && repo?.repo
32
+ ? [repo.repo, { ...(repo.task && { task: repo.task }), ...(repo.budget && { budget: repo.budget }), ...opts }]
33
+ : [repo, opts]);
34
+
35
+ export async function plan(repoOrEntry, opts = {}) {
36
+ let [repo, { budget = DEFAULT_BUDGET, task = null, file = null, backends = BACKENDS, caps, model, fetch }] = entry(repoOrEntry, opts);
37
+ caps ??= await probe();
38
+ model ??= await describe(repo, fetch ? { fetch } : {});
39
+ return { model, caps, budget, task, file, ...(await resolve(model, caps, backends, { budget, task, file })) };
40
+ }
41
+
42
+ export async function load(repoOrEntry, options = {}) {
43
+ const [repo, opts] = entry(repoOrEntry, options);
44
+ const { backends = BACKENDS, maxCtx, dtype, poolBytes, localFile, onProgress, signal } = opts;
45
+ let p;
46
+ try { p = await plan(repo, opts); } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${repo}: `); }
47
+ if (!p.chosen) throw noBackend(p);
48
+ const backend = backends.find((b) => b.id === p.chosen);
49
+ let session;
50
+ try {
51
+ session = await backend.load(p.model, { budget: p.budget, task: p.task, file: p.file, caps: p.caps, maxCtx, dtype, poolBytes, localFile, onProgress, signal });
52
+ } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${backend.id}: `); }
53
+ return { backend: backend.id, task: session.task || p.task || p.model.task || null, ...(session.device && { device: session.device }), plan: p, ...serialize(session) };
54
+ }
55
+
56
+ // content: a string, or an array of { type, … } blocks (text, image, audio) for multimodal models.
57
+ const validContent = (c) => typeof c === 'string' || (Array.isArray(c) && c.every((b) => typeof b?.type === 'string'));
58
+ const validMessages = (messages) => Array.isArray(messages) && messages.every((m) => typeof m?.role === 'string' && validContent(m?.content));
59
+
60
+ // One call at a time per session: two overlapping generates would share one KV cache. A call waits for the previous
61
+ // one to finish (or for its caller to stop iterating). dispose() waits for the running call, then frees the GPU;
62
+ // calls after it throw DISPOSED.
63
+ function serialize(session) {
64
+ let tail = Promise.resolve(), disposed = false;
65
+ const gone = () => new AnyformerError('DISPOSED', 'this session was disposed; load the model again');
66
+ const turn = async () => {
67
+ let release;
68
+ const mine = new Promise((r) => { release = r; });
69
+ const prev = tail;
70
+ tail = prev.then(() => mine);
71
+ await prev;
72
+ return release;
73
+ };
74
+ const generate = session.generate && async function* (messages, o = {}) {
75
+ if (disposed) throw gone();
76
+ 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');
77
+ const release = await turn();
78
+ try {
79
+ if (disposed) throw gone();
80
+ for await (const chunk of session.generate(messages, o)) yield chunk;
81
+ } catch (e) {
82
+ throw wrap(e, 'EXECUTION_FAILED', o.signal);
83
+ } finally {
84
+ release();
85
+ }
86
+ };
87
+ // A call that waits its turn like generate(): prime and ensureVision share the engine with it.
88
+ const queued = (fn) => async (...args) => {
89
+ if (disposed) throw gone();
90
+ const release = await turn();
91
+ try {
92
+ if (disposed) throw gone();
93
+ return await fn(...args);
94
+ } catch (e) {
95
+ throw wrap(e, 'EXECUTION_FAILED');
96
+ } finally {
97
+ release();
98
+ }
99
+ };
100
+ return {
101
+ ...(generate && { generate }),
102
+ // Where reasoning starts and ends as token ids ({ openId, closeId, inPrompt, preamble, always }), or null.
103
+ ...('think' in session && { think: session.think }),
104
+ ...(session.prime && { prime: queued(session.prime) }),
105
+ ...(session.ensureVision && { ensureVision: queued(session.ensureVision) }),
106
+ // The one call every task has. For text generation it is the last chunk of generate().
107
+ async run(input, o = {}) {
108
+ if (generate) { let last; for await (const c of generate(input, o)) last = c; return last; }
109
+ if (disposed) throw gone();
110
+ const release = await turn();
111
+ try {
112
+ if (disposed) throw gone();
113
+ return await session.run(input, o);
114
+ } catch (e) {
115
+ throw wrap(e, 'EXECUTION_FAILED', o.signal);
116
+ } finally {
117
+ release();
118
+ }
119
+ },
120
+ async dispose() {
121
+ if (disposed) return;
122
+ disposed = true;
123
+ await tail;
124
+ await session.dispose?.();
125
+ },
126
+ };
127
+ }
package/src/models.js ADDED
@@ -0,0 +1,50 @@
1
+ // Models that pass a browser test through anyformer, grouped by what they do. Pass an entry to load() in place of a
2
+ // repo id; it carries the task and, where the back end needs one, the budget. DEFAULT is the smallest download that
3
+ // passed. `test` names the page under test/browser/ that proves the entry; test/models.test.js checks it exists.
4
+
5
+ const M = (repo, backend, task, test, extra = {}) => ({ repo, backend, task, test, ...extra });
6
+
7
+ const QWEN2_5_0_5B = M('onnx-community/Qwen2.5-0.5B-Instruct', 'transformers', 'text-generation', 'transformers', { downloadBytes: 786e6 });
8
+ const TERNARY_BONSAI_4B = M('onnx-community/Ternary-Bonsai-4B-ONNX', 'transformers', 'text-generation', 'transformers', { downloadBytes: 1.16e9 });
9
+ const LFM2_5_230M = M('LiquidAI/LFM2.5-230M-GGUF', 'localmind', 'text-generation', 'chat', { downloadBytes: 149e6 });
10
+ const LFM2_5_1_2B = M('LiquidAI/LFM2.5-1.2B-Instruct-GGUF', 'localmind', 'text-generation', 'chat', { downloadBytes: 696e6 });
11
+ const GEMMA4_E2B = M('google/gemma-4-E2B-it-qat-mobile-transformers', 'localmind', 'text-generation', 'chat', { downloadBytes: 2.46e9 });
12
+ const GEMMA4_E4B = M('google/gemma-4-E4B-it-qat-mobile-transformers', 'localmind', 'text-generation', 'chat', { budget: 4e9, downloadBytes: 3.53e9 });
13
+ const TERNARY_BONSAI_2_27B = M('prism-ml/Ternary-Bonsai-2-27B-gguf', 'localmind', 'text-generation', 'chat', { budget: 7e9, downloadBytes: 5.94e9 });
14
+ const QWEN3_5_4B = M('onnx-community/Qwen3.5-4B-ONNX-OPT', 'transformers', 'text-generation', 'vision', { budget: 3.1e9, downloadBytes: 3.0e9 });
15
+ const GEMMA4_E2B_ONNX = M('onnx-community/gemma-4-E2B-it-ONNX', 'transformers', 'text-generation', 'vision', { budget: 3.5e9, downloadBytes: 3.38e9 });
16
+ const GEMMA4_E4B_ONNX = M('onnx-community/gemma-4-E4B-it-ONNX', 'transformers', 'text-generation', 'transformers', { budget: 5.3e9, downloadBytes: 5.18e9 });
17
+ const MINICPM5_2B = M('openbmb/MiniCPM5-2B-GGUF', 'wllama', 'text-generation', 'chat', { downloadBytes: 1.56e9 });
18
+ const QWEN3_0_6B_MDLM = M('naklitechie/Qwen3-0.6B-diffusion-mdlm-ONNX', 'kohra', 'text-generation', 'kohra', { downloadBytes: 1.54e9 });
19
+ const GEMMA4_26B_A4B = M('google/gemma-4-26B-A4B-it-qat-q4_0-gguf', 'diskformer', 'text-generation', 'diskformer-chat', { budget: 2.5e9, downloadBytes: 14.44e9 });
20
+ const MINILM_L6 = M('Xenova/all-MiniLM-L6-v2', 'transformers', 'feature-extraction', 'embed', { downloadBytes: 90e6 });
21
+ const DETR_RESNET_50 = M('Xenova/detr-resnet-50', 'transformers', 'object-detection', 'detect', { downloadBytes: 167e6 });
22
+
23
+ export const MODELS = {
24
+ chat: {
25
+ DEFAULT: QWEN2_5_0_5B,
26
+ QWEN2_5_0_5B,
27
+ /** Ternary weights (q2 graph), 4B parameters in 1.16 GB. */
28
+ TERNARY_BONSAI_4B,
29
+ /** LocalMind's hand-written LFM2 kernels: 149 MB, the fastest chat here. */
30
+ LFM2_5_230M,
31
+ /** The same LFM2 engine on the 1.2B GGUF. */
32
+ LFM2_5_1_2B,
33
+ /** Gemma 4 E2B, Google's QAT-mobile export, on LocalMind's Gemma kernels. */
34
+ GEMMA4_E2B,
35
+ /** Gemma 4 E4B on the same kernels; the whole 3.5 GB stays on the GPU. */
36
+ GEMMA4_E4B,
37
+ /** 27B parameters at 1.6 bits (Prism ternary), 5.9 GB on the GPU. */
38
+ TERNARY_BONSAI_2_27B,
39
+ /** llama.cpp in WebAssembly (wllama): WebGPU when the budget holds it, the CPU otherwise. */
40
+ MINICPM5_2B,
41
+ /** Text diffusion: the whole reply denoises at once (kohra). */
42
+ QWEN3_0_6B_MDLM,
43
+ /** 26B parameters in 2.5 GB of GPU memory; 14.4 GB on disk (diskformer). */
44
+ GEMMA4_26B_A4B,
45
+ },
46
+ /** Chat with images in the messages: content [{ type: 'image', image }, { type: 'text', text }]. Gemma 4 takes audio too. */
47
+ vision: { DEFAULT: QWEN3_5_4B, QWEN3_5_4B, GEMMA4_E2B_ONNX, GEMMA4_E4B_ONNX },
48
+ embedding: { DEFAULT: MINILM_L6, MINILM_L6 },
49
+ objectDetection: { DEFAULT: DETR_RESNET_50, DETR_RESNET_50 },
50
+ };
@@ -0,0 +1,27 @@
1
+ // transformers.js's pipeline() call shape over anyformer's resolver, so `import { pipeline } from 'anyformer'` replaces
2
+ // `from '@huggingface/transformers'` for the calls below. Any repo anyformer can plan works: GGUF on diskformer,
3
+ // text diffusion on kohra, ONNX on transformers.js.
4
+ //
5
+ // const gen = await pipeline('text-generation', 'google/gemma-4-26B-A4B-it-qat-q4_0-gguf', { budget: 2.5e9 });
6
+ // const [{ generated_text }] = await gen(messages, { max_new_tokens: 128 }); // messages in → messages + reply out
7
+ // const [{ generated_text: text }] = await gen('Once upon a time'); // a string in → the reply string out
8
+ //
9
+ // Other tasks return what the session's run() returns (feature-extraction: plain arrays, not a Tensor).
10
+ // Options: budget, maxCtx, backends, progress_callback (transformers.js's name) or onProgress, signal.
11
+
12
+ import { load } from './load.js';
13
+ import { AnyformerError } from './errors.js';
14
+
15
+ export async function pipeline(task, repo, options = {}) {
16
+ if (!repo) throw new AnyformerError('INVALID_ARGUMENT', 'pipeline(task, repo): anyformer needs a repo id or a MODELS entry');
17
+ const { progress_callback, ...opts } = options;
18
+ const session = await load(repo, { ...opts, task, onProgress: opts.onProgress || progress_callback });
19
+ const call = task === 'text-generation'
20
+ ? async (input, o = {}) => {
21
+ const messages = typeof input === 'string' ? [{ role: 'user', content: input }] : input;
22
+ const reply = await session.run(messages, { maxNewTokens: o.max_new_tokens ?? o.maxNewTokens ?? 256, signal: o.signal });
23
+ return [{ generated_text: typeof input === 'string' ? reply.text : [...messages, { role: 'assistant', content: reply.text }] }];
24
+ }
25
+ : (input, o) => session.run(input, o);
26
+ return Object.assign(call, { task, backend: session.backend, plan: session.plan, session, dispose: () => session.dispose() });
27
+ }
package/src/probe.js ADDED
@@ -0,0 +1,37 @@
1
+ // What this browser can do, read once per page. Every field has a safe value when an API is missing (Node, Firefox,
2
+ // a page without WebGPU), so a back end's canRun() reads caps without guarding.
3
+
4
+ // A wasm module with one memory whose flags byte is 0x04 (memory64). It validates only where memory64 is supported.
5
+ const MEMORY64_PROBE = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 5, 3, 1, 4, 0]);
6
+
7
+ export function memory64() {
8
+ try { return WebAssembly.validate(MEMORY64_PROBE); } catch { return false; }
9
+ }
10
+
11
+ export async function probe({ gpu = globalThis.navigator?.gpu, storage = globalThis.navigator?.storage } = {}) {
12
+ const caps = {
13
+ webgpu: false, f16: false, subgroups: false,
14
+ maxBufferSize: 0, maxStorageBufferBindingSize: 0, adapter: null,
15
+ opfs: false, quotaBytes: 0, usageBytes: 0,
16
+ memory64: memory64(),
17
+ // JSPI: wasm that suspends on a promise (diskformer's llama.cpp build reads experts from OPFS this way).
18
+ jspi: typeof globalThis.WebAssembly?.Suspending === 'function',
19
+ };
20
+ const adapter = gpu ? await gpu.requestAdapter({ powerPreference: 'high-performance' }).catch(() => null) : null;
21
+ if (adapter) {
22
+ caps.webgpu = true;
23
+ caps.f16 = adapter.features.has('shader-f16');
24
+ caps.subgroups = adapter.features.has('subgroups');
25
+ caps.maxBufferSize = adapter.limits.maxBufferSize;
26
+ caps.maxStorageBufferBindingSize = adapter.limits.maxStorageBufferBindingSize;
27
+ const info = adapter.info || {};
28
+ caps.adapter = { vendor: info.vendor || '', architecture: info.architecture || '', description: info.description || '' };
29
+ }
30
+ if (storage?.getDirectory) {
31
+ caps.opfs = true;
32
+ const est = storage.estimate ? await storage.estimate().catch(() => null) : null;
33
+ caps.quotaBytes = est?.quota || 0;
34
+ caps.usageBytes = est?.usage || 0;
35
+ }
36
+ return caps;
37
+ }
package/src/resolve.js ADDED
@@ -0,0 +1,49 @@
1
+ // Ask every back end whether it can run the model under the budget, then rank the ones that can.
2
+ //
3
+ // Back-end contract (each back end is a plain object):
4
+ // id string, stable, e.g. 'diskformer'
5
+ // tasks the Hugging Face pipeline tasks it runs, e.g. ['text-generation']
6
+ // canRun(model, caps, { budget, task, file }) → { ok: true, estimate } | { ok: false, code, reason, need? }
7
+ // file: a weights file the caller chose (a GGUF name); a back end that cannot load that file refuses.
8
+ // estimate = { gpuBytes, diskBytes, tokPerSec | null }. No network, no GPU allocation: it reads the
9
+ // descriptor and caps only. code is one of REFUSALS below; need is the budget that would pass, with
10
+ // code 'budget'. A throw counts as code 'threw' with the message as the reason.
11
+ // load(model, { budget, caps, maxCtx, dtype, onProgress, signal }) → session (maxCtx: tokens; dtype: a precision
12
+ // the back end offers, e.g. 'q4f16' for transformers.js; undefined = the back end's default)
13
+ // session = { run(input, opts) → result, dispose() }; a text-generation session also has
14
+ // generate(messages, opts) → async iterable of { text, tokens }, and run(messages) is its last chunk.
15
+ // task is the requested pipeline task, or null for the repo's own (model.task).
16
+ // The back end applies the model's own chat template.
17
+
18
+ // Why a back end said no: a closed set, each with the move that changes the answer.
19
+ export const REFUSALS = {
20
+ format: 'the repo has no weights in a format this back end reads; pick a repo in another format',
21
+ model: 'this back end reads the format but not this model (architecture or file); pick another model',
22
+ task: 'this back end does not run the requested task; another back end or another repo does',
23
+ webgpu: 'no WebGPU adapter; use Chrome or Edge 121+ on a GPU with WebGPU',
24
+ f16: 'the GPU lacks shader-f16; use a GPU that has it',
25
+ opfs: 'no OPFS; use a browser with navigator.storage.getDirectory, outside private mode',
26
+ budget: 'the GPU budget is too small; pass { budget } at least `need` bytes',
27
+ threw: 'the back end crashed deciding; a bug in that back end, report it with the reason',
28
+ };
29
+
30
+ export async function resolve(model, caps, backends, { budget, task = null, file = null } = {}) {
31
+ const candidates = [];
32
+ for (const [order, b] of backends.entries()) {
33
+ let r;
34
+ try { r = await b.canRun(model, caps, { budget, task, file }); } catch (e) { r = { ok: false, code: 'threw', reason: `canRun threw: ${e.message}` }; }
35
+ candidates.push(r.ok ? { backend: b.id, order, ok: true, estimate: r.estimate } : { backend: b.id, order, ok: false, code: r.code || 'model', reason: r.reason, ...(r.need && { need: r.need }) });
36
+ }
37
+ const ranked = candidates.filter((c) => c.ok).sort(rank);
38
+ return { chosen: ranked[0]?.backend ?? null, ranked: ranked.map((c) => c.backend), candidates };
39
+ }
40
+
41
+ // A back end with a speed estimate ranks above one without; two estimates rank by tok/s; otherwise the order the
42
+ // caller listed them in, which is the default preference.
43
+ function rank(a, b) {
44
+ const ta = a.estimate?.tokPerSec ?? null;
45
+ const tb = b.estimate?.tokPerSec ?? null;
46
+ if (ta !== null && tb !== null && ta !== tb) return tb - ta;
47
+ if ((ta === null) !== (tb === null)) return ta === null ? 1 : -1;
48
+ return a.order - b.order;
49
+ }
package/src/sources.js ADDED
@@ -0,0 +1,23 @@
1
+ // Where each back end's runtime loads from: exact versions and commits, so a given anyformer behaves the same tomorrow.
2
+ // kohra and diskformer move fast on their main branches; `node scripts/pin-sources.mjs` moves these pins
3
+ // to each repo's current main, and the browser tests then prove the new pins (test/browser/README.md).
4
+ // transformers.js and wllama move only by hand, in step with LocalMind's pins (plan/history.md, 2026-10-08).
5
+
6
+ export const PINS = {
7
+ transformers: '4.3.1',
8
+ wllama: '3.4.1', // LocalMind's pin; its stream-drain workaround (src/backends/wllama.js) is for this version
9
+ kohra: 'f678fac7342c79b58b640540b8b3a7d078b7ab1a',
10
+ // v0.6.4: every engine (the disk-tier MoE engines, Ling 3.0 Flash, and LocalMind's whole-model engines LFM2.5,
11
+ // Gemma 4 E2B/E4B and Ternary Bonsai 2) under engines/, and the llama.cpp path under ggml/.
12
+ diskformer: '095ab2058be9de56841c51b9369ba556d625392b',
13
+ };
14
+
15
+ const gh = (repo, sha, path) => `https://cdn.jsdelivr.net/gh/NakliTechie/${repo}@${sha}/${path}`;
16
+
17
+ export const SOURCES = {
18
+ transformers: `https://cdn.jsdelivr.net/npm/@huggingface/transformers@${PINS.transformers}/+esm`,
19
+ wllama: `https://cdn.jsdelivr.net/npm/@wllama/wllama@${PINS.wllama}/esm`,
20
+ kohra: gh('kohra', PINS.kohra, 'kohra.js'),
21
+ diskformer: gh('diskformer.js', PINS.diskformer, 'engines'),
22
+ diskformerLlama: gh('diskformer.js', PINS.diskformer, 'ggml/llama.js'),
23
+ };