anyformer 0.0.1 → 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 +36 -0
- package/README.md +106 -17
- package/SPEC.md +68 -0
- package/index.js +16 -0
- package/package.json +41 -6
- package/src/backends/diskformer.js +80 -0
- package/src/backends/kohra.js +59 -0
- package/src/backends/localmind.js +115 -0
- package/src/backends/transformers.js +191 -0
- package/src/backends/wllama.js +122 -0
- package/src/describe.js +34 -0
- package/src/errors.js +36 -0
- package/src/gguf-split.js +160 -0
- package/src/load.js +106 -0
- package/src/models.js +50 -0
- package/src/pipeline.js +27 -0
- package/src/probe.js +35 -0
- package/src/resolve.js +48 -0
- package/src/sources.js +22 -0
- package/src/store.js +126 -0
- package/src/tools.js +148 -0
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/README.md
CHANGED
|
@@ -1,30 +1,119 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">anyformer.js</h1>
|
|
2
2
|
|
|
3
|
-
One call to run
|
|
4
|
-
GPU budget
|
|
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)
|
|
10
|
+
[](package.json)
|
|
5
11
|
|
|
6
12
|
## Install
|
|
7
13
|
|
|
8
|
-
|
|
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.
|
|
9
74
|
|
|
10
|
-
##
|
|
75
|
+
## Commands
|
|
11
76
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
```
|
|
14
88
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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`.
|
|
19
94
|
|
|
20
|
-
|
|
95
|
+
## Verify it yourself
|
|
21
96
|
|
|
22
|
-
|
|
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
|
+
```
|
|
23
111
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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).
|
|
27
115
|
|
|
28
116
|
## License
|
|
29
117
|
|
|
30
|
-
MIT
|
|
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,13 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anyformer",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "One call to run
|
|
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
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
|
+
],
|
|
6
38
|
"license": "MIT",
|
|
7
39
|
"author": "Chirag Patnaik",
|
|
8
40
|
"homepage": "https://github.com/NakliTechie/anyformer.js#readme",
|
|
9
|
-
"repository": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
}
|
|
13
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 };
|