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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-14
4
+
5
+ The first release with code. `anyformer@0.0.1` reserved the name and held none.
6
+
7
+ ### Added
8
+ - `plan(repo)` and `load(repo)`: read the Hub repo, probe the browser, ask every back end, run the best yes. A refusal
9
+ carries a closed code (`format`, `model`, `task`, `webgpu`, `f16`, `opfs`, `budget`, `threw`) and one remedy in `REFUSALS`.
10
+ - Five back ends:
11
+ - `transformers`: transformers.js 4.3.1. Text generation, image and audio chat (Gemma 4, Qwen3.5), embeddings,
12
+ speech to text, detection, classification, depth, segmentation.
13
+ - `kohra`: text diffusion (MDLM, BD3LM) over onnxruntime-web.
14
+ - `localmind`: LocalMind's hand-written WebGPU kernels (LFM2 / LFM2.5, Gemma 4 E2B / E4B, Ternary Bonsai 2).
15
+ - `wllama`: llama.cpp in WebAssembly for any other GGUF up to about 3.6 GB, on WebGPU or the CPU; files over 2 GB
16
+ split into OPFS shards once.
17
+ - `diskformer`: weights in OPFS, only what a step needs on the GPU (Gemma 4 26B-A4B from 2.5 GB, Qwen3.6 35B-A3B from 4 GB).
18
+ - `MODELS`: models that pass a browser test, loadable by entry.
19
+ - `pipeline(task, repo)`: transformers.js's call shape over any back end.
20
+ - `stored()` and `remove(repo)`: what is downloaded in this browser, across every back end.
21
+ - Coded errors: `AnyformerError` with a code from `ERROR_CODES`, checked with `isAnyformerError(e, code)`.
22
+ - One `generate()` at a time per session, in call order.
23
+ - Options: `load()` takes `task`, `maxCtx`, `dtype` and `signal`; `generate()` takes `maxNewTokens`, `repetitionPenalty`
24
+ and `signal`; each back end's `.source` (for example `kohraBackend.source`) overrides where its runtime loads from.
25
+ - Pinned runtimes in `src/sources.js` (exact versions and commits); `npm run pin` moves them.
26
+ - Agent face: 9 tools over one manifest, through WebMCP's `navigator.modelContext` or `window.anyformer.tools`.
27
+ - The workbench (`examples/chat/`): pick a model, see the plan, then chat, compare embeddings or detect objects.
28
+
29
+ ### Measured (Chrome, M4 Pro, 24 GB)
30
+ - 13 of 13 LocalMind Hub models plan at an 8 GB budget; 12 ran in a browser test.
31
+ - 10 of 10 replies byte-identical with LocalMind direct on the same transformers.js pin.
32
+ - Speed 0.99–1.02× LocalMind direct on the steady back ends.
33
+
34
+ ### Not tested yet
35
+ Audio input blocks, abort mid-reply, speech / classification / depth / segmentation in a browser, any machine other
36
+ than the M4 Pro, and Qwen3.6 35B-A3B (it needs more than this machine's 24 GB).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chirag Patnaik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">anyformer.js</h1>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **One call to run a model in a browser tab: anyformer reads the repo, probes the browser, and picks the back end that
4
+ fits the model's format, architecture and your GPU budget.**
5
+
6
+ Plain ES modules, no build step, no dependencies to install. Chromium with WebGPU (GGUF models also run on the CPU). Weights stay in the user's own
7
+ browser storage; no account, no server, no telemetry.
8
+
9
+ [![license](https://img.shields.io/badge/license-MIT-555?style=flat-square)](LICENSE)
10
+ [![dependencies](https://img.shields.io/badge/dependencies-none-555?style=flat-square)](package.json)
11
+
12
+ ## Install
13
+
14
+ | How | Command |
15
+ |---|---|
16
+ | npm | `npm install anyformer` |
17
+ | CDN | `import { load } from 'https://cdn.jsdelivr.net/npm/anyformer@0.1.0/+esm';` |
18
+ | Copy | `index.js` and `src/`; nothing else is needed |
19
+
20
+ ```js
21
+ import { load } from 'anyformer';
22
+
23
+ const chat = await load('onnx-community/Qwen2.5-0.5B-Instruct', { budget: 2.5e9, onProgress: console.log });
24
+ for await (const { text } of chat.generate([{ role: 'user', content: 'What does a CPU do?' }], { maxNewTokens: 64 }))
25
+ show(text);
26
+ ```
27
+
28
+ Every session has `run(input)`: chat messages for text generation (content a string, or blocks such as
29
+ `[{ type: 'image', image }, { type: 'text', text }]` for Gemma 4 and Qwen3.5), a string or strings for embeddings, ImageData for
30
+ vision, 16 kHz samples for speech. `load(repo, { task })` picks a task when a repo has more than one. The first load
31
+ downloads the weights into the browser; later loads read them from there. Each back end's runtime loads
32
+ from jsDelivr only when that back end is chosen.
33
+
34
+ ## Try it
35
+
36
+ Serve the repo and open the workbench: pick a model, see which back end anyformer chose and why every other one
37
+ refused, then chat (with an image for the vision models), compare embeddings, or detect objects; remove downloads.
38
+
39
+ ```bash
40
+ python3 -m http.server 8000 # then open http://localhost:8000/examples/chat/
41
+ ```
42
+
43
+ ## Why
44
+
45
+ You have a model id and a browser tab. Whether it runs depends on its format (ONNX, GGUF), its architecture, whether it
46
+ is autoregressive or diffusion, and how much GPU memory the tab can take. Each runtime covers part of that, and you
47
+ find out which part by failing to load.
48
+
49
+ anyformer asks every back end first. `plan(repo)` returns the model's description, the browser's capabilities and each
50
+ back end's answer: yes with an estimate (GPU bytes, disk bytes, tok/s when measured), or no with a code and a reason.
51
+ `load(repo)` runs the best yes.
52
+
53
+ ## Back ends
54
+
55
+ | Back end | Runs | Models |
56
+ |---|---|---|
57
+ | `transformers` | transformers.js 4.3.1: text generation, image and audio chat (Gemma 4, Qwen3.5), embeddings, speech to text, detection, classification, depth, segmentation | ONNX repos in the onnx-community / Xenova layout |
58
+ | `kohra` | kohra, text-diffusion over onnxruntime-web | MDLM and BD3LM repos tagged `text-diffusion` |
59
+ | `localmind` | LocalMind's hand-written WebGPU kernels, the whole model on the GPU | any LFM2 / LFM2.5 GGUF; Gemma 4 E2B / E4B QAT-mobile; Ternary Bonsai 2 |
60
+ | `wllama` | llama.cpp in WebAssembly: WebGPU when the budget holds the model, the CPU otherwise (no WebGPU needed) | any GGUF up to about 3.6 GB that no engine above covers; files over 2 GB are split into OPFS shards once |
61
+ | `diskformer` | diskformer engines: weights in OPFS, only what a step needs on the GPU | Gemma 4 26B-A4B (from 2.5 GB), Qwen3.6 35B-A3B (from 4 GB) |
62
+
63
+ Each runtime loads from an exact version or commit in [src/sources.js](src/sources.js); set a back end's `.source`
64
+ (for example `kohraBackend.source`) to load another copy. `BACKENDS` lists the back ends `load()` asks by default: each joins after its browser test passes.
65
+
66
+ ## When no back end can run it
67
+
68
+ `load()` throws an `AnyformerError` with code `NO_BACKEND` and the plan, naming each back end, its code and its
69
+ reason. Codes are a closed set, each with one
70
+ remedy in `REFUSALS`: `format`, `model`, `task`, `webgpu`, `f16`, `opfs`, `budget` (with `need`, the budget that would pass)
71
+ and `threw`. Every other failure is an `AnyformerError` too, with a code from `ERROR_CODES` (`LOAD_FAILED`,
72
+ `BUDGET_TOO_SMALL`, `ABORTED`, `EXECUTION_FAILED`, `INVALID_ARGUMENT`, `DISPOSED`); check it with
73
+ `isAnyformerError(e, code)`. A session runs one `generate()` at a time, in call order.
74
+
75
+ ## Commands
76
+
77
+ ```js
78
+ plan(repo, { budget, task }) // what would run it, and why the others would not; loads nothing
79
+ load(repo, { budget, task, maxCtx, onProgress, signal }) // → { backend, task, plan, run(input), generate?, dispose() }
80
+ pipeline(task, repo, { budget }) // transformers.js's call shape: gen(messages) → [{ generated_text }]
81
+ probe() // WebGPU, shader-f16, subgroups, limits, OPFS quota, memory64
82
+ describe(repo) // Hub repo → formats, architecture, task, tags, files
83
+ resolve(model, caps, backends, { budget }) // rank back ends for a described model
84
+ stored() · remove(repo) // what is downloaded in this browser; delete one
85
+ MODELS.chat.DEFAULT // models that pass a browser test; load(MODELS.chat.GEMMA4_26B_A4B) works too
86
+ registerTools() // publish the agent face (below)
87
+ ```
88
+
89
+ Agents get the same calls as tools (`anyformer_models`, `_probe`, `_plan`, `_load`, `_generate`, `_sessions`, `_unload`,
90
+ `_stored`, `_remove`) through
91
+ WebMCP's `navigator.modelContext` where the browser has it and `window.anyformer.tools` otherwise; `anyformer_load`
92
+ loads nothing until the caller accepts the plan's disk bytes, and `anyformer_remove` deletes nothing without
93
+ `confirm: true`.
94
+
95
+ ## Verify it yourself
96
+
97
+ ```bash
98
+ npm test # Node: probe, describe, resolver, back-end verdicts, agent face
99
+ npm run test:browser -- probe # headless Chrome: WebGPU, f16, OPFS
100
+ npm run test:browser -- transformers # Qwen2.5-0.5B answers on topic
101
+ npm run test:browser -- kohra # Qwen3-0.6B MDLM answers on topic
102
+ npm run test:browser -- embed # MiniLM embeddings rank a paraphrase above an unrelated sentence
103
+ npm run test:browser -- detect # DETR finds both cats in cats.jpg
104
+ npm run test:browser -- examples/chat/index.html --query "auto=1" # the workbench loads and answers
105
+ npm run test:browser -- pipeline --port 8192 --profile ~/.cache/anyformer-chat-profile # pipeline() on a GGUF
106
+ npm run test:browser -- store # list a download, remove it
107
+ npm run test:browser -- chat --query "repo=LiquidAI/LFM2.5-1.2B-Instruct-GGUF" # a LocalMind engine
108
+ npm run test:browser -- vision --query "repo=onnx-community/Qwen3.5-4B-ONNX-OPT" # names the two cats
109
+ npm run test:browser -- diskformer-chat # Gemma 4 26B-A4B at 2.5 GB: 9/9 replies identical to llama.cpp b9830
110
+ ```
111
+
112
+ The browser tests fail a reply that is off topic or repeats itself, and `diskformer-chat` fails any reply that differs
113
+ from llama.cpp's by one character. Only Apple silicon (M4 Pro, Chrome) has run them. Not tested yet: audio input blocks, abort mid-reply, speech / classification / depth / segmentation in a browser, any
114
+ machine other than the M4 Pro, and Qwen3.6 35B-A3B (it needs more than that machine's 24 GB).
115
+
116
+ ## License
117
+
118
+ MIT. Spec and agent contract: [SPEC.md](SPEC.md) · depends on [diskformer.js](https://github.com/NakliTechie/diskformer.js)
119
+ · [kohra](https://github.com/NakliTechie/kohra) · [LocalMind](https://github.com/NakliTechie/LocalMind)
package/SPEC.md ADDED
@@ -0,0 +1,68 @@
1
+ # anyformer.js — spec
2
+
3
+ Tier: Product (several back ends, a resolver, one store). anyformer depends on diskformer and kohra; neither depends on
4
+ anyformer.
5
+
6
+ ## §0 Agent contract (DRIVER v0.1 pass, 2026-10-08)
7
+
8
+ The driver: an agent in a browser tab (or a script) that must run a model it names by repo id, with no idea which
9
+ runtime fits, how much GPU memory the tab may take, or whether a download is 0.5 GB or 36 GB. It is context-poor, may
10
+ be killed mid-turn, and must not burn a user's disk or bandwidth by guessing.
11
+
12
+ 1. **One perception act: `anyformer_plan` (`plan()`).** One call returns the model's formats, architecture and task,
13
+ the browser's caps, the budget, the chosen back end, and every back end's verdict. The agent never probes or
14
+ fetches separately before deciding.
15
+ 2. **Closed vocabularies.** Errors from load() and a session are `AnyformerError` with `code` in `ERROR_CODES`
16
+ (NO_BACKEND · LOAD_FAILED · BUDGET_TOO_SMALL+need · ABORTED · EXECUTION_FAILED · INVALID_ARGUMENT · DISPOSED),
17
+ checked by name and code (`isAnyformerError`), so two copies of the library agree. A refusal is `{code, reason, need?}` with `code` in `REFUSALS`: format · model · task · webgpu ·
18
+ f16 · opfs · budget · threw. Each code has one remedy (`REFUSALS[code]`); `budget` carries `need`, the bytes that
19
+ would pass. Tool consequences are `read` · `stages` · `mutates`. Tool errors are `{error}` data, never throws.
20
+ 3. **One verdict per next action.** `chosen` set → load. `chosen` null → read the codes: format/model → another repo;
21
+ webgpu/f16/opfs → another browser or GPU (nothing in this tab fixes it); budget → re-plan with `need`; threw → a bug.
22
+ 4. **Bounded output.** `anyformer_plan` grows with the number of back ends, never with the repo's file count (the file
23
+ list stays out). The journal holds the last 50 calls. `generate` returns one reply.
24
+ 5. **Every failure names its remedy.** NO_BACKEND lists each back end with its code and reason; `REFUSALS[code]`
25
+ is the next move.
26
+ 6. **Crash-safe and idempotent.** A session runs one generate at a time; dispose() waits for it and is idempotent. `plan` and `probe` have no side effects. Weights land in OPFS through diskformer's
27
+ resumable ingest (an interrupted download resumes); transformers.js and kohra use the browser HTTP cache. Loading
28
+ twice loads twice; it never corrupts a store.
29
+ 7. **The tool holds the memory.** `anyformer_sessions` renders the open sessions and the last 50 tool calls with the
30
+ door each came through (`direct`, `window`, `modelContext`).
31
+ 8. **Accretion.** A back end joins `BACKENDS` only after its browser test passes (test/registry.test.js pins the list);
32
+ every escaped wrong answer becomes a browser-test check (the transformers.js q4f16 word salad became `onTopic`).
33
+ 9. **The tower.** One line per layer; each reads only the one below.
34
+ - `probe` (browser caps) · `describe` (Hub repo → descriptor)
35
+ - back ends: `canRun` (pure, descriptor + caps) · `load` → session
36
+ - `resolve` (rank the verdicts) → `plan` (probe + describe + resolve) → `load` (plan + back-end load)
37
+ - agent face: `TOOLS` manifest → `call` → WebMCP `registerTool` / `window.anyformer.tools`
38
+ 10. **The evaluator stays outside.** The browser tests (test/browser/) judge back ends against fixed references:
39
+ llama.cpp b9830 replies for diskformer, an on-topic check for transformers.js and kohra. A page that times out
40
+ exits 2 (indeterminate), never 0. Staging is enforced in the tool, not left to the agent: `anyformer_load` loads
41
+ nothing unless `acceptDiskBytes` covers the plan's disk bytes.
42
+
43
+ Self-check: perception act ✓ · closed vocabularies ✓ · output bound ✓ · crash-safety ✓ · trajectory ✓ · accretion ✓ ·
44
+ tower ✓ · evaluator boundary ✓.
45
+
46
+ ## What shipped (Batch A)
47
+
48
+ - `load(repo, {budget, maxCtx, backends, onProgress, signal})` → `{backend, plan, generate(messages, {maxNewTokens, signal}),
49
+ dispose()}`; `generate` yields `{text, tokens}`, `text` cumulative.
50
+ - Tasks: `plan/load(repo, { task })`, default the repo's pipeline tag; every session has `run(input)`, text generation
51
+ also `generate()`. Back ends list their `tasks`.
52
+ - Back ends: transformers.js 4.3.1 (ONNX text-generation, q4 first), kohra @main (text-diffusion ONNX), diskformer
53
+ @main (pinned Gemma 4 26B-A4B and Qwen3.6 35B-A3B GGUFs). transformers.js also runs feature-extraction, speech recognition, object
54
+ detection, image classification, depth, segmentation, token and text classification on fp32 graphs. kohra and diskformer load from their main branches;
55
+ `<backend>.source` pins another copy.
56
+ - transformers.js also runs Gemma 4 and Qwen3.5 ONNX as chat with image (and, for Gemma 4, audio) blocks, through
57
+ their conditional-generation classes at q4f16, as LocalMind does.
58
+ - localmind @main: LocalMind's hand-written engines, matched by architecture and format (lfm2 GGUF, Gemma 4
59
+ QAT-mobile, qwen35 Prism ternary); resident on the GPU, so budget ≥ file size.
60
+ - wllama 3.4.1 (LocalMind's pin): any GGUF ≤ ~3.6 GB no architecture engine covers (a repo's split set as is; a
61
+ single file over 2 GB split once into OPFS shards, src/gguf-split.js); WebGPU when the budget
62
+ holds it, CPU otherwise. Thinking off (chat_template_kwargs). Ranks last.
63
+ - Pins: src/sources.js holds exact versions and commits; `npm run pin` moves the git pins to each repo's main.
64
+ - Agent face: 9 tools (models, probe, plan, load, generate, sessions, unload, stored, remove). Person-only acts: none.
65
+ - `MODELS`: tested entries by family; `load(entry)`. `stored()` / `remove(repo)` over the transformers.js cache and
66
+ diskformer's OPFS stores.
67
+ - Gaps: no wasm fallback (every back end needs WebGPU); one store per back end, not the shared OPFS store yet (kohra's graph is in the HTTP cache, unlisted);
68
+ llama.cpp-webgpu not started; speed estimates exist for diskformer only.
package/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // anyformer — one call to run any model in a browser tab. Plain ESM, no build step.
2
+ export { load, plan, BACKENDS, DEFAULT_BUDGET } from './src/load.js';
3
+ export { AnyformerError, isAnyformerError, ERROR_CODES } from './src/errors.js';
4
+ export { probe, memory64 } from './src/probe.js';
5
+ export { describe, fromHubInfo } from './src/describe.js';
6
+ export { resolve, REFUSALS } from './src/resolve.js';
7
+ export { transformersBackend } from './src/backends/transformers.js';
8
+ export { kohraBackend } from './src/backends/kohra.js';
9
+ export { diskformerBackend, DISKFORMER_MODELS } from './src/backends/diskformer.js';
10
+ export { TOOLS, call, registerTools, journal, toolOptions } from './src/tools.js';
11
+ export { MODELS } from './src/models.js';
12
+ export { stored, remove } from './src/store.js';
13
+ export { pipeline } from './src/pipeline.js';
14
+ export { localmindBackend } from './src/backends/localmind.js';
15
+ export { wllamaBackend } from './src/backends/wllama.js';
16
+ export { PINS, SOURCES } from './src/sources.js';
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "anyformer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "src/",
13
+ "README.md",
14
+ "LICENSE",
15
+ "CHANGELOG.md",
16
+ "SPEC.md"
17
+ ],
18
+ "sideEffects": false,
19
+ "scripts": {
20
+ "test": "node --test 'test/*.test.js'",
21
+ "test:browser": "node test/browser/run.mjs",
22
+ "pin": "node scripts/pin-sources.mjs"
23
+ },
24
+ "keywords": [
25
+ "agent",
26
+ "browser",
27
+ "diffusion",
28
+ "diskformer",
29
+ "gguf",
30
+ "inference",
31
+ "llm",
32
+ "localmind",
33
+ "onnx",
34
+ "transformers",
35
+ "webgpu",
36
+ "webmcp"
37
+ ],
38
+ "license": "MIT",
39
+ "author": "Chirag Patnaik",
40
+ "homepage": "https://github.com/NakliTechie/anyformer.js#readme",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/NakliTechie/anyformer.js.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/NakliTechie/anyformer.js/issues"
47
+ }
48
+ }
@@ -0,0 +1,80 @@
1
+ // diskformer: MoE models larger than GPU memory. Weights stay in OPFS; the GPU holds the dense layers, the KV cache
2
+ // and an expert cache sized by the budget. Engines load from the CDN at load() time, at the commit src/sources.js pins;
3
+ // set diskformerBackend.source to load another copy.
4
+
5
+ import { AnyformerError } from '../errors.js';
6
+ import { SOURCES } from '../sources.js';
7
+
8
+ // The GGUFs diskformer's engines are pinned to (GEMMA4_26B_A4B, QWEN36_35B_A3B in engines/*.js), with the smallest budget each
9
+ // has passed its gate at and the measured decode speeds (diskformer README, M4 Pro, 2026-10-05).
10
+ export const DISKFORMER_MODELS = [
11
+ {
12
+ repo: 'google/gemma-4-26B-A4B-it-qat-q4_0-gguf', file: 'gemma-4-26B_q4_0-it.gguf', size: 14439363584,
13
+ engine: 'gemma4_moe_ssd.js', Engine: 'Gemma4MoeSsd', minBudget: 2.5e9,
14
+ speeds: [[2.5e9, 12.4], [4.3e9, 14.2], [7e9, 17.9]],
15
+ },
16
+ {
17
+ repo: 'unsloth/Qwen3.6-35B-A3B-GGUF', file: 'Qwen3.6-35B-A3B-Q8_0.gguf', size: 36903140320,
18
+ engine: 'qwen35_moe_ssd.js', Engine: 'Qwen35MoeSsd', minBudget: 4e9,
19
+ speeds: [[4e9, 9.2]],
20
+ },
21
+ ];
22
+
23
+ // diskformer's engines refuse a budget with a plain Error ("GPU budget 2.50 GB is too small: … need 3.04 GB, and the
24
+ // expert cache at least 0.05 GB more"). Until they carry a code, read the two numbers to give BUDGET_TOO_SMALL a need.
25
+ function budgetError(e) {
26
+ const m = /GPU budget [\d.]+ GB is too small\b.*?need ([\d.]+) GB.*?at least ([\d.]+) GB more/s.exec(e?.message || '');
27
+ return m && new AnyformerError('BUDGET_TOO_SMALL', `diskformer: ${e.message}`, { cause: e, need: Math.ceil((+m[1] + +m[2]) * 1e9) });
28
+ }
29
+
30
+ const match = (model) => DISKFORMER_MODELS.find((m) => m.repo === model.repo &&
31
+ model.files.gguf.some((f) => f.name === m.file && f.size === m.size));
32
+
33
+ // The speed measured at the largest budget not above this one.
34
+ const speedAt = (m, budget) => m.speeds.filter(([b]) => b <= budget).at(-1)?.[1] ?? null;
35
+
36
+ export { budgetError };
37
+
38
+ export const diskformerBackend = {
39
+ id: 'diskformer',
40
+ // Gemma 4's repo is tagged image-text-to-text; the engines run its text turns.
41
+ tasks: ['text-generation', 'image-text-to-text'],
42
+ source: SOURCES.diskformer,
43
+
44
+ canRun(model, caps, { budget, task }) {
45
+ if (!model.formats.includes('gguf')) return { ok: false, code: 'format', reason: 'no GGUF in the repo' };
46
+ if (task && !diskformerBackend.tasks.includes(task)) return { ok: false, code: 'task', reason: `task ${task}; diskformer engines run text generation only` };
47
+ const m = match(model);
48
+ if (!m) return { ok: false, code: 'model', reason: `no diskformer engine for this GGUF; engines cover ${DISKFORMER_MODELS.map((x) => x.file).join(', ')}` };
49
+ if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
50
+ if (!caps.f16) return { ok: false, code: 'f16', reason: 'the GPU lacks shader-f16' };
51
+ if (!caps.opfs) return { ok: false, code: 'opfs', reason: 'no OPFS (navigator.storage.getDirectory)' };
52
+ if (budget < m.minBudget) return { ok: false, code: 'budget', need: m.minBudget, reason: `a ${(budget / 1e9).toFixed(1)} GB budget is under the ${m.minBudget / 1e9} GB this model has passed its gate at` };
53
+ // Storage quota is not checked: Chrome raises it as the download writes, while the disk has room.
54
+ return { ok: true, estimate: { gpuBytes: budget, diskBytes: m.size, tokPerSec: speedAt(m, budget) } };
55
+ },
56
+
57
+ // maxCtx sizes the KV cache, which stays on the GPU: the 2.5 GB minimum and the speeds above hold at 1024 tokens,
58
+ // the context diskformer's chat gate runs at. A longer context needs a larger budget.
59
+ async load(model, { budget, maxCtx = 1024, onProgress, signal }) {
60
+ const m = match(model);
61
+ const mod = await import(`${diskformerBackend.source}/${m.engine}`);
62
+ let engine;
63
+ try {
64
+ engine = await mod[m.Engine].load(null, {
65
+ // The engines' own default root, named so the store module (src/store.js) lists the same folder.
66
+ gpuBudgetBytes: budget, maxCtx, signal, root: 'localmind-ssd',
67
+ onProgress: onProgress && ((e) => onProgress({ backend: 'diskformer', ...e })),
68
+ });
69
+ } catch (e) { throw budgetError(e) || e; }
70
+ return {
71
+ task: 'text-generation',
72
+ engine,
73
+ async *generate(messages, { maxNewTokens = 512, signal } = {}) {
74
+ for await (const r of engine.generate(messages, { maxNewTokens, signal, enableThinking: false }))
75
+ yield { text: r.text, tokens: r.tokens };
76
+ },
77
+ dispose: () => engine.dispose(),
78
+ };
79
+ },
80
+ };
@@ -0,0 +1,59 @@
1
+ // kohra: text-diffusion LMs (MDLM, BD3LM). kohra.js runs the denoising loop over onnxruntime-web; it loads from the
2
+ // CDN at load() time, at the commit src/sources.js pins; set kohraBackend.source to load another copy.
3
+
4
+ import { graphBytes } from './transformers.js';
5
+ import { SOURCES } from '../sources.js';
6
+
7
+ const FP16 = 'onnx/model_fp16_fused.onnx';
8
+ // The q4 graph decodes on WebGPU only with RTN packing and this ORT-web dev build (kohra README, "Known constraints").
9
+ const Q4 = 'onnx/model_q4f16_rtn_sym.onnx';
10
+ const Q4_ORT = '1.26.0-dev.20260416-b7804b056c';
11
+
12
+ const pickGraph = (model, caps) => {
13
+ const have = new Set(model.files.onnx.map((f) => f.name));
14
+ return caps.f16 && have.has(FP16) ? FP16 : have.has(Q4) ? Q4 : null;
15
+ };
16
+
17
+ // kohra's own template covers one user turn; this is the same ChatML over the whole conversation.
18
+ const chatml = (messages) =>
19
+ messages.map((m) => `<|im_start|>${m.role}\n${m.content}<|im_end|>\n`).join('') + '<|im_start|>assistant\n';
20
+
21
+ export const kohraBackend = {
22
+ id: 'kohra',
23
+ tasks: ['text-generation'],
24
+ source: SOURCES.kohra,
25
+
26
+ canRun(model, caps, { task } = {}) {
27
+ if (!model.tags?.includes('text-diffusion')) return { ok: false, code: 'model', reason: 'not a text-diffusion model' };
28
+ if (task && task !== 'text-generation') return { ok: false, code: 'task', reason: `task ${task}; kohra runs text-generation only` };
29
+ if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
30
+ const graph = pickGraph(model, caps);
31
+ if (!graph) return { ok: false, code: 'format', reason: `no kohra graph (${FP16} or ${Q4}) in the repo` };
32
+ const bytes = graphBytes(model, graph);
33
+ return { ok: true, estimate: { gpuBytes: bytes, diskBytes: bytes, tokPerSec: null } };
34
+ },
35
+
36
+ async load(model, { caps, onProgress, signal }) {
37
+ const graph = pickGraph(model, caps);
38
+ const { DiffusionLM } = await import(kohraBackend.source);
39
+ onProgress?.({ backend: 'kohra', status: 'loading', file: graph });
40
+ const base = `https://huggingface.co/${model.repo}/resolve/${model.revision || 'main'}`;
41
+ const lm = await DiffusionLM.from_pretrained({
42
+ model: `${base}/${graph}`, tokenizer: model.repo, ...(graph === Q4 && { ortVersion: Q4_ORT }),
43
+ });
44
+ signal?.throwIfAborted();
45
+ const blockCausal = model.tags.includes('block-diffusion');
46
+ return {
47
+ task: 'text-generation',
48
+ // Diffusion fills the whole block at once, so the reply arrives as one chunk.
49
+ async *generate(messages, { maxNewTokens = 128, steps, signal } = {}) {
50
+ signal?.throwIfAborted();
51
+ const out = await lm.generate(chatml(messages), {
52
+ maxNewTokens, steps: steps ?? maxNewTokens, blockCausal, chatTemplate: false, stripThink: true,
53
+ });
54
+ yield { text: out.text, tokens: out.tokens, tokPerSec: out.tokensPerSecond };
55
+ },
56
+ dispose: () => lm.session?.release?.(),
57
+ };
58
+ },
59
+ };
@@ -0,0 +1,115 @@
1
+ // LocalMind's hand-written WebGPU engines: whole model resident on the GPU, kernels written per architecture. Each
2
+ // engine reads its config from the model file, so it runs any checkpoint of its architecture in a format it reads,
3
+ // not only the one LocalMind ships. They load from the CDN at load() time, at the commit src/sources.js pins; set
4
+ // localmindBackend.source to load another copy.
5
+ //
6
+ // lfm2 src/lfm2_5.js GGUF, general.architecture 'lfm2' (dense LFM2 / LFM2.5), Q4_0 Q8_0 Q4_K Q5_K Q6_K
7
+ // gemma4 src/gemma-4-e2b.js Google's QAT-mobile safetensors export of Gemma 4 (E2B, E4B): config.json-driven
8
+ // qwen35 src/ternary_bonsai_2_27b.js GGUF, 'qwen35' with Prism's ternary types (PTQ1_0, PQ2_0) only: a standard
9
+ // K-quant (unsloth/Qwen3.5-4B-GGUF Q4_K_M) fails with "This feature is not included in this bundle".
10
+ // Speeds: LocalMind's quiet-machine engine bench (plan/bench-engines-quiet-2026-09-24.md, M4 Pro, 256 tokens, greedy).
11
+
12
+ import { SOURCES } from '../sources.js';
13
+
14
+ const ENGINES = {
15
+ lfm2: { file: 'lfm2_5.js', Engine: 'Lfm2Mobile' },
16
+ gemma4: { file: 'gemma-4-e2b.js', Engine: 'Gemma4Mobile' },
17
+ qwen35: { file: 'ternary_bonsai_2_27b.js', Engine: 'TernaryBonsai2' },
18
+ };
19
+
20
+ // Quant preference per engine, by the GGUF file-name suffix. Plain Q4_0 first: it is LocalMind's default.
21
+ const LFM2_QUANTS = ['Q4_0', 'Q4_K_M', 'Q4_K_S', 'Q5_K_M', 'Q5_K_S', 'Q6_K', 'Q8_0'];
22
+ const QWEN35_QUANTS = ['PTQ1_0', 'PQ2_0'];
23
+ const MEASURED = {
24
+ 'LiquidAI/LFM2.5-230M-GGUF': 1062.5,
25
+ 'google/gemma-4-E2B-it-qat-mobile-transformers': 171.3,
26
+ 'prism-ml/Ternary-Bonsai-2-27B-gguf': 27.2,
27
+ };
28
+
29
+ // The GGUF to load: the first quant in preference order. Projector (mmproj) files are vision towers, never the LM.
30
+ function pickGguf(model, quants) {
31
+ const lms = model.files.gguf.filter((f) => !/mmproj/i.test(f.name));
32
+ for (const q of quants) {
33
+ const f = lms.find((x) => new RegExp(`[-_.]${q}\\.gguf$`, 'i').test(x.name) && !/QAD/i.test(x.name));
34
+ if (f) return f;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ // Which engine reads this repo, and the file and bytes it would load; null when none does.
40
+ function match(model) {
41
+ if (model.formats.includes('gguf') && model.architecture === 'lfm2') {
42
+ const f = pickGguf(model, LFM2_QUANTS);
43
+ return f && { engine: 'lfm2', file: f.name, bytes: f.size };
44
+ }
45
+ if (model.formats.includes('gguf') && model.architecture === 'qwen35') {
46
+ const f = pickGguf(model, QWEN35_QUANTS);
47
+ return f && { engine: 'qwen35', file: f.name, bytes: f.size };
48
+ }
49
+ // The QAT-mobile export's tensor names (weight_scale, embedding_quantized …) are what the engine reads; a plain
50
+ // Gemma 4 safetensors checkpoint has model_type gemma4 too but will not load, so the repo name decides.
51
+ if (model.architecture === 'gemma4' && /-qat-mobile-transformers$/.test(model.repo)) {
52
+ const f = model.files.safetensors.find((x) => x.name === 'model.safetensors');
53
+ return f && { engine: 'gemma4', file: f.name, bytes: f.size };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export const localmindBackend = {
59
+ id: 'localmind',
60
+ tasks: ['text-generation'],
61
+ source: SOURCES.localmind,
62
+
63
+ canRun(model, caps, { budget, task }) {
64
+ const m = match(model);
65
+ if (!m) return { ok: false, code: 'model', reason: 'no LocalMind engine reads this repo (lfm2 GGUF, qwen35 Prism GGUF, Gemma 4 QAT-mobile)' };
66
+ if (task && task !== 'text-generation') return { ok: false, code: 'task', reason: `task ${task}; LocalMind's engines run text generation` };
67
+ if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
68
+ if (m.engine === 'qwen35' && !(caps.f16 && caps.subgroups)) return { ok: false, code: 'f16', reason: 'the ternary kernels need shader-f16 and subgroups' };
69
+ // The whole model stays on the GPU, so the budget must hold the file.
70
+ if (budget < m.bytes) return { ok: false, code: 'budget', need: m.bytes, reason: `${m.file} stays on the GPU whole (${(m.bytes / 1e9).toFixed(2)} GB) and the budget is ${(budget / 1e9).toFixed(2)} GB` };
71
+ return { ok: true, estimate: { gpuBytes: m.bytes, diskBytes: m.bytes, tokPerSec: MEASURED[model.repo] ?? null } };
72
+ },
73
+
74
+ async load(model, { maxCtx, onProgress, signal }) {
75
+ const m = match(model);
76
+ const e = ENGINES[m.engine];
77
+ const mod = await import(`${localmindBackend.source}/${e.file}`);
78
+ const opts = {
79
+ revision: model.revision || 'main', signal,
80
+ onProgress: onProgress && ((p) => onProgress({ backend: 'localmind', engine: m.engine, ...p })),
81
+ ...(m.engine !== 'gemma4' && { file: m.file }),
82
+ ...(maxCtx && m.engine !== 'gemma4' && { maxLength: maxCtx }),
83
+ };
84
+ const engine = await mod[e.Engine].load(model.repo, opts);
85
+ signal?.throwIfAborted();
86
+ // Thinking off, as the diskformer back end does: a chat reply, not the model's reasoning. (The qwen35 engine
87
+ // takes template arguments as a field; LocalMind sets the same one.)
88
+ if ('chatTemplateArgs' in engine) engine.chatTemplateArgs = { ...engine.chatTemplateArgs, enable_thinking: false };
89
+ // Compile the kernels now, as LocalMind's workers do, so the first reply is not paying for it.
90
+ onProgress?.({ backend: 'localmind', engine: m.engine, status: 'warmup' });
91
+ await engine.warmup?.();
92
+ // Gemma's template has no system turn; LocalMind folds the system prompt into the first user turn the same way.
93
+ const prepare = m.engine !== 'gemma4' ? (msgs) => msgs : (msgs) => {
94
+ const sys = msgs.filter((x) => x.role === 'system').map((x) => x.content).join('\n\n');
95
+ const rest = msgs.filter((x) => x.role !== 'system');
96
+ const i = rest.findIndex((x) => x.role === 'user');
97
+ return sys && i >= 0 ? rest.map((x, k) => (k === i ? { ...x, content: `${sys}\n\n${x.content}` } : x)) : rest;
98
+ };
99
+ let tokens = 0;
100
+ return {
101
+ task: 'text-generation',
102
+ engine,
103
+ async *generate(messages, { maxNewTokens = 512, signal } = {}) {
104
+ tokens = 0;
105
+ for await (const c of engine.generate(prepare(messages), { maxNewTokens, signal })) {
106
+ if (c.token != null) tokens++;
107
+ yield { text: c.text, tokens };
108
+ }
109
+ },
110
+ dispose: () => engine.dispose?.(),
111
+ };
112
+ },
113
+ };
114
+
115
+ export { match as localmindMatch };