anyformer 0.3.1 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 — 2026-10-23
4
+
5
+ ### Fixed
6
+ - `session.speculative` and `session.pleMode` were copied once when `load()` returned, because the session object was
7
+ built with spreads, which read getters. A Bonsai session's `speculative.status` stayed `'loading'` after DFlash or
8
+ MTP attached. They are now read live. A test through `load()` failed on 0.4.0 and passes now.
9
+
10
+ ### Added
11
+ - `session.engine`: the engine instance the `diskformer` and `localmind` back ends built, read-only, for token-level
12
+ work a caller already does with it (Bonsai 2's tokenizer and think-marker ids in LocalMind's worker). Calls on it
13
+ bypass the session's one-call-at-a-time queue.
14
+
15
+ ## 0.4.0 — 2026-10-23
16
+
17
+ What LocalMind's own workers do with its last three engine families (LFM2.5, Gemma 4 E2B/E4B, Ternary Bonsai 2 27B),
18
+ on the `localmind` back end, so those models can move onto anyformer with nothing lost.
19
+
20
+ ### Added
21
+ - `load(repo, { fetch })`: the engines' fetch, for a caller's retrying fetch on multi-GB range reads.
22
+ - `load(repo, { ple: true })` for Gemma 4 E2B/E4B: the per-layer-embedding table streams from OPFS (diskformer's
23
+ `ple-opfs.js`); `session.pleMode` reports the mode.
24
+ - `load(repo, { speculative })` for Ternary Bonsai 2 27B: `{ dflash: { block } }` (DFlash 2) or
25
+ `{ mtp: { depth, adaptive } }`, attached after the model is ready so the first reply never waits for it. The drafter
26
+ and the MTP head download once into Cache Storage, under LocalMind's names and keys, so a browser that holds them
27
+ reuses them. `session.speculative` gives `{ status, error, stats }`. Output is greedy-identical either way.
28
+ - `generate(messages, { thinking, assistantPrefix })` on the `localmind` back end: thinking per request (Bonsai's
29
+ template, `reasoning_effort: 'medium'`) and a raw prefix to continue from; chunks carry `token`.
30
+ - `session.reset()` where the engine has it, queued with `generate()`.
31
+
32
+ ### Measured
33
+ - LFM2.5 230M through the `localmind` back end: the same reply as before these changes. Node tests cover each option
34
+ with stand-in engines (83/83). Gemma's PLE table and Bonsai's DFlash and MTP have not run through anyformer in a
35
+ browser; LocalMind's A/B per model is that check.
36
+
37
+ ## 0.3.2 — 2026-10-23
38
+
39
+ ### Changed
40
+ - Nothing loads from the LocalMind repo any more. diskformer v0.6.4 (095ab20) holds LocalMind's whole-model
41
+ engines too (LFM2.5, Gemma 4 E2B/E4B, Ternary Bonsai 2 27B; MIT, confirmed by their author), and the `localmind`
42
+ back end loads them from diskformer's `engines/`. The id stays `localmind`. `PINS.localmind` and
43
+ `SOURCES.localmind` are gone.
44
+ - LFM2.5 230M through the `localmind` back end from diskformer's copy: the same reply as from LocalMind's commit
45
+ 203288a (the engine file is byte-identical), on topic.
46
+
3
47
  ## 0.3.1 — 2026-10-23
4
48
 
5
49
  ### Added
package/README.md CHANGED
@@ -76,7 +76,7 @@ and `threw`. Every other failure is an `AnyformerError` too, with a code from `E
76
76
 
77
77
  ```js
78
78
  plan(repo, { budget, task }) // what would run it, and why the others would not; loads nothing
79
- load(repo, { budget, task, file, maxCtx, poolBytes, localFile, onProgress, signal }) // → { backend, task, plan, run(input), generate?, think?, prime?, dispose() }
79
+ load(repo, { budget, task, file, maxCtx, poolBytes, localFile, fetch, ple, speculative, onProgress, signal }) // → { backend, task, plan, run(input), generate?, think?, prime?, reset?, speculative?, dispose() }
80
80
  pipeline(task, repo, { budget }) // transformers.js's call shape: gen(messages) → [{ generated_text }]
81
81
  probe() // WebGPU, shader-f16, subgroups, limits, OPFS quota, memory64
82
82
  describe(repo) // Hub repo → formats, architecture, task, tags, files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyformer",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "One call to run a model in a browser tab: anyformer reads the Hub repo, probes the browser, and picks the back end (transformers.js, LocalMind's WebGPU kernels, diskformer, kohra) that fits the model's format, architecture and your GPU budget.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -72,23 +72,33 @@ export const localmindBackend = {
72
72
  return { ok: true, estimate: { gpuBytes: m.bytes, diskBytes: m.bytes, tokPerSec: MEASURED[model.repo] ?? null } };
73
73
  },
74
74
 
75
- async load(model, { maxCtx, onProgress, signal }) {
75
+ // Options beyond the shared ones, as LocalMind's workers use the engines:
76
+ // fetch the engines' fetch (a caller's retrying fetch for multi-GB range reads)
77
+ // ple Gemma 4: stream the per-layer-embedding table from OPFS (ple-opfs.js) instead of holding it
78
+ // speculative Ternary Bonsai 2: { dflash: { block } } or { mtp: { depth, adaptive } }, attached after the model is
79
+ // ready, so the first reply never waits for the drafter's download (src/backends/speculative.js)
80
+ async load(model, { maxCtx, fetch, ple, speculative, onProgress, signal }) {
76
81
  const m = match(model);
77
82
  const e = ENGINES[m.engine];
78
- const mod = await import(`${localmindBackend.source}/${e.file}`);
83
+ const engines = localmindBackend.source;
84
+ const mod = await import(`${engines}/${e.file}`);
85
+ const say = onProgress && ((p) => onProgress({ backend: 'localmind', engine: m.engine, ...p }));
86
+ let pleTable = null;
87
+ if (ple && m.engine === 'gemma4') {
88
+ const { createGemmaPle } = await import(`${engines}/ple-opfs.js`);
89
+ pleTable = await createGemmaPle({ source: { repo: model.repo }, onStatus: (s) => say?.({ status: 'ple', ...s }) });
90
+ }
79
91
  const opts = {
80
- revision: model.revision || 'main', signal,
81
- onProgress: onProgress && ((p) => onProgress({ backend: 'localmind', engine: m.engine, ...p })),
92
+ revision: model.revision || 'main', signal, onProgress: say,
93
+ ...(fetch && { fetch }),
94
+ ...(pleTable && { ple: pleTable }),
82
95
  ...(m.engine !== 'gemma4' && { file: m.file }),
83
96
  ...(maxCtx && m.engine !== 'gemma4' && { maxLength: maxCtx }),
84
97
  };
85
98
  const engine = await mod[e.Engine].load(model.repo, opts);
86
99
  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
100
  // 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' });
101
+ say?.({ status: 'warmup' });
92
102
  await engine.warmup?.();
93
103
  // Gemma's template has no system turn; LocalMind folds the system prompt into the first user turn the same way.
94
104
  const prepare = m.engine !== 'gemma4' ? (msgs) => msgs : (msgs) => {
@@ -97,18 +107,43 @@ export const localmindBackend = {
97
107
  const i = rest.findIndex((x) => x.role === 'user');
98
108
  return sys && i >= 0 ? rest.map((x, k) => (k === i ? { ...x, content: `${sys}\n\n${x.content}` } : x)) : rest;
99
109
  };
110
+ // Speculative decoding, Bonsai only, in the background: a reply that starts before it lands decodes plainly.
111
+ const spec = { status: speculative && m.engine === 'qwen35' ? 'loading' : 'off', runner: null, error: null, disposed: false };
112
+ if (spec.status === 'loading') {
113
+ import('./speculative.js')
114
+ .then(({ attachSpeculative }) => attachSpeculative(engine, speculative, { engines, fetch, signal, onProgress: (p) => say?.({ ...p, speculative: true }) }))
115
+ .then((runner) => {
116
+ // Disposed while the drafter downloaded: detach and free it rather than attach to a dead engine.
117
+ if (spec.disposed) { engine.model.dflashRunner = null; runner.dispose?.(); return; }
118
+ spec.runner = runner; spec.status = 'ready';
119
+ }, (err) => { spec.error = err.message; spec.status = 'error'; });
120
+ }
100
121
  let tokens = 0;
101
122
  return {
102
123
  task: 'text-generation',
103
124
  engine,
104
- async *generate(messages, { maxNewTokens = 512, signal } = {}) {
125
+ // Gemma 4 with the OPFS table: 'resident' or the streamed mode the engine reports.
126
+ ...(m.engine === 'gemma4' && { get pleMode() { return engine.pleMode ?? null; } }),
127
+ // { status: 'off' | 'loading' | 'ready' | 'error', error, stats }; stats are the runner's counters.
128
+ get speculative() { return { status: spec.status, error: spec.error, stats: spec.runner?.stats ?? null }; },
129
+ // thinking: per request, through the qwen35 engine's template arguments (reasoning_effort 'medium', LocalMind's
130
+ // serving recipe); the other two engines have no thinking switch. assistantPrefix: raw text the reply continues
131
+ // from (Bonsai's think-budget continuation). Chunks carry the engine's token id.
132
+ async *generate(messages, { maxNewTokens = 512, signal, thinking = false, assistantPrefix } = {}) {
133
+ if ('chatTemplateArgs' in engine) engine.chatTemplateArgs = { ...engine.chatTemplateArgs, enable_thinking: thinking === true, reasoning_effort: 'medium' };
105
134
  tokens = 0;
106
- for await (const c of engine.generate(prepare(messages), { maxNewTokens, signal })) {
135
+ for await (const c of engine.generate(prepare(messages), { maxNewTokens, signal, ...(assistantPrefix != null && { assistantPrefix }) })) {
107
136
  if (c.token != null) tokens++;
108
- yield { text: c.text, tokens };
137
+ yield { text: c.text, token: c.token, tokens };
109
138
  }
110
139
  },
111
- dispose: () => engine.dispose?.(),
140
+ // Drops the KV and recurrent state, for a new conversation or a shorter history.
141
+ ...(typeof engine.reset === 'function' && { reset: () => engine.reset() }),
142
+ dispose: () => {
143
+ spec.disposed = true;
144
+ if (spec.runner) { engine.model.dflashRunner = null; spec.runner.dispose?.(); }
145
+ return engine.dispose?.();
146
+ },
112
147
  };
113
148
  },
114
149
  };
@@ -0,0 +1,96 @@
1
+ // Speculative decoding for Ternary Bonsai 2 27B: DFlash 2 (a 5-layer drafter) or MTP (one extra decoder layer that
2
+ // drafts ahead). Both are greedy-identical to the engine's own decode; they only change how many tokens one verify
3
+ // pass accepts. The runners live in diskformer (engines/ternary_bonsai_2_dflash.js and ternary_bonsai_2_mtp.js) and
4
+ // attach to the engine through its specDecodeRunner() seam (engine.model.dflashRunner).
5
+ //
6
+ // Their weights are separate downloads, kept in Cache Storage under the names and keys LocalMind uses, so a browser
7
+ // that already holds them from LocalMind reuses them:
8
+ // 'dflash-drafter-v1' the drafter GGUF (1.14 GB), keyed by DRAFTER_URL
9
+ // 'mtp-head-v1' the MTP head's byte ranges (451 MB) of a grafted GGUF, keyed by MTP_HEAD.url
10
+
11
+ // LocalMind's drafter: naklitechie/Qwen3.8-27B-DFlash2-ternary-bonsai2 (index.html DRAFTER_URL, main febbc93).
12
+ export const DRAFTER_URL = 'https://huggingface.co/naklitechie/Qwen3.8-27B-DFlash2-ternary-bonsai2/resolve/main/Qwen3.8-27B-DFlash2-r3-Q4_K_M.gguf';
13
+ export const DRAFTER_CACHE = 'dflash-drafter-v1';
14
+ export const MTP_CACHE = 'mtp-head-v1';
15
+
16
+ // One file's byte ranges ([[start, length], …], or the whole file) as a Blob, from Cache Storage or downloaded in 32 MB
17
+ // pieces, 8 in flight (Hugging Face's CDN throttles each connection), then stored. A Blob keeps a 1 GB file on disk
18
+ // rather than in the JS heap.
19
+ export async function cachedBlob({ cacheName, url, ranges = null, fileSize = null, fetch = globalThis.fetch, onProgress, signal }) {
20
+ const cache = await caches.open(cacheName);
21
+ const want = ranges ? ranges.reduce((n, [, len]) => n + len, 0) : null;
22
+ const hit = await cache.match(url);
23
+ if (hit) { const b = await hit.blob(); if (want === null || b.size === want) return b; }
24
+ if (!ranges) {
25
+ const probe = await fetch(url, { headers: { Range: 'bytes=0-0' }, signal });
26
+ if (probe.status !== 206) throw new Error(`${url}: HTTP ${probe.status}; the server must support Range`);
27
+ fileSize = Number((probe.headers.get('content-range') || '').split('/')[1]) || 0;
28
+ try { await probe.body?.cancel(); } catch { /* nothing to cancel */ }
29
+ if (!fileSize) throw new Error(`${url}: unknown size`);
30
+ ranges = [[0, fileSize]];
31
+ }
32
+ const PIECE = 32 << 20, STREAMS = 8;
33
+ const jobs = [];
34
+ for (const [start, len] of ranges) for (let o = 0; o < len; o += PIECE) jobs.push([start + o, Math.min(PIECE, len - o)]);
35
+ const total = jobs.reduce((n, [, len]) => n + len, 0);
36
+ const pieces = new Array(jobs.length);
37
+ let next = 0, loaded = 0;
38
+ const worker = async () => {
39
+ for (;;) {
40
+ const i = next++;
41
+ if (i >= jobs.length) return;
42
+ const [start, len] = jobs[i];
43
+ const r = await fetch(url, { headers: { Range: `bytes=${start}-${start + len - 1}` }, signal });
44
+ if (r.status !== 206) throw new Error(`${url}: HTTP ${r.status} on bytes ${start}+${len}`);
45
+ if (fileSize && Number((r.headers.get('content-range') || '').split('/')[1]) !== fileSize) throw new Error(`${url}: the file is not ${fileSize} bytes`);
46
+ const buf = new Uint8Array(await r.arrayBuffer());
47
+ if (buf.byteLength !== len) throw new Error(`${url}: short read on bytes ${start}+${len}`);
48
+ pieces[i] = buf;
49
+ loaded += len;
50
+ onProgress?.({ loaded, total });
51
+ }
52
+ };
53
+ await Promise.all(Array.from({ length: STREAMS }, worker));
54
+ const blob = new Blob(pieces, { type: 'application/octet-stream' });
55
+ // A failed write only costs a download next time.
56
+ await cache.put(url, new Response(blob, { headers: { 'content-type': 'application/octet-stream', 'content-length': String(blob.size) } })).catch(() => {});
57
+ return blob;
58
+ }
59
+
60
+ const blobSource = (blob) => async (off, len) => new Uint8Array(await blob.slice(off, off + len).arrayBuffer());
61
+
62
+ // Attaches a runner to a loaded Ternary Bonsai 2 engine. spec: { dflash: { block = 5 } } or
63
+ // { mtp: { depth = 2, adaptive = false } }. engines: the diskformer engines/ URL. Returns the runner; its .stats
64
+ // counts cycles, tokens and accepted drafts (MTP adds drafted and plainTokens).
65
+ export async function attachSpeculative(engine, spec, { engines, fetch, onProgress, signal } = {}) {
66
+ if (spec.dflash) {
67
+ const { DFlashRunner } = await import(`${engines}/ternary_bonsai_2_dflash.js`);
68
+ const blob = await cachedBlob({ cacheName: DRAFTER_CACHE, url: DRAFTER_URL, fetch, signal,
69
+ onProgress: (p) => onProgress?.({ status: 'download', kind: 'dflash', ...p }) });
70
+ signal?.throwIfAborted();
71
+ const runner = await DFlashRunner.create(engine, { drafter: blobSource(blob), block: spec.dflash.block ?? 5,
72
+ onProgress: (p) => onProgress?.({ status: 'load', kind: 'dflash', loaded: p.done, total: p.total }) });
73
+ engine.model.dflashRunner = runner;
74
+ return runner;
75
+ }
76
+ if (spec.mtp) {
77
+ const { MTPRunner, MTP_HEAD } = await import(`${engines}/ternary_bonsai_2_mtp.js`);
78
+ const blob = await cachedBlob({ cacheName: MTP_CACHE, url: MTP_HEAD.url, ranges: MTP_HEAD.ranges, fileSize: MTP_HEAD.fileSize, fetch, signal,
79
+ onProgress: (p) => onProgress?.({ status: 'download', kind: 'mtp', ...p }) });
80
+ signal?.throwIfAborted();
81
+ // Absolute file offsets → offsets in the blob, which holds the ranges back to back.
82
+ const at = [];
83
+ let acc = 0;
84
+ for (const [start, len] of MTP_HEAD.ranges) { at.push([start, len, acc]); acc += len; }
85
+ const head = async (off, len) => {
86
+ const r = at.find(([s, l]) => off >= s && off + len <= s + l);
87
+ if (!r) throw new Error(`MTP head: bytes ${off}+${len} are outside the downloaded ranges`);
88
+ return blobSource(blob)(r[2] + off - r[0], len);
89
+ };
90
+ const runner = await MTPRunner.create(engine, { head, depth: spec.mtp.depth ?? 2, adaptive: !!spec.mtp.adaptive,
91
+ onProgress: (p) => onProgress?.({ status: 'load', kind: 'mtp', loaded: p.done, total: p.total }) });
92
+ engine.model.dflashRunner = runner;
93
+ return runner;
94
+ }
95
+ throw new Error('speculative: pass { dflash: { block } } or { mtp: { depth, adaptive } }');
96
+ }
package/src/load.js CHANGED
@@ -41,16 +41,17 @@ export async function plan(repoOrEntry, opts = {}) {
41
41
 
42
42
  export async function load(repoOrEntry, options = {}) {
43
43
  const [repo, opts] = entry(repoOrEntry, options);
44
- const { backends = BACKENDS, maxCtx, dtype, poolBytes, localFile, onProgress, signal } = opts;
44
+ const { backends = BACKENDS, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, onProgress, signal } = opts;
45
45
  let p;
46
46
  try { p = await plan(repo, opts); } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${repo}: `); }
47
47
  if (!p.chosen) throw noBackend(p);
48
48
  const backend = backends.find((b) => b.id === p.chosen);
49
49
  let session;
50
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 });
51
+ session = await backend.load(p.model, { budget: p.budget, task: p.task, file: p.file, caps: p.caps, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, onProgress, signal });
52
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) };
53
+ // Added onto serialize()'s object, not spread: a spread would read its getters once and freeze them.
54
+ return Object.assign(serialize(session), { backend: backend.id, task: session.task || p.task || p.model.task || null, ...(session.device && { device: session.device }), plan: p });
54
55
  }
55
56
 
56
57
  // content: a string, or an array of { type, … } blocks (text, image, audio) for multimodal models.
@@ -97,12 +98,13 @@ function serialize(session) {
97
98
  release();
98
99
  }
99
100
  };
100
- return {
101
+ const out = {
101
102
  ...(generate && { generate }),
102
103
  // Where reasoning starts and ends as token ids ({ openId, closeId, inPrompt, preamble, always }), or null.
103
104
  ...('think' in session && { think: session.think }),
104
105
  ...(session.prime && { prime: queued(session.prime) }),
105
106
  ...(session.ensureVision && { ensureVision: queued(session.ensureVision) }),
107
+ ...(session.reset && { reset: queued(session.reset) }),
106
108
  // The one call every task has. For text generation it is the last chunk of generate().
107
109
  async run(input, o = {}) {
108
110
  if (generate) { let last; for await (const c of generate(input, o)) last = c; return last; }
@@ -124,4 +126,9 @@ function serialize(session) {
124
126
  await session.dispose?.();
125
127
  },
126
128
  };
129
+ // Read live from the back end's session, never copied: the engine instance it built (diskformer and localmind back
130
+ // ends; read-only, for token-level work a caller already does with the engine; calls on it bypass the queue),
131
+ // Gemma 4's PLE mode, and Bonsai's speculative decoding state, which changes after load.
132
+ for (const k of ['engine', 'pleMode', 'speculative']) if (k in session) Object.defineProperty(out, k, { get: () => session[k], enumerable: true });
133
+ return out;
127
134
  }