anyformer 0.1.0 → 0.3.2
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 +106 -0
- package/README.md +5 -4
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/backends/diskformer-llama.js +161 -0
- package/src/backends/diskformer.js +52 -8
- package/src/backends/localmind.js +4 -3
- package/src/describe.js +17 -4
- package/src/load.js +27 -6
- package/src/probe.js +2 -0
- package/src/resolve.js +4 -3
- package/src/sources.js +5 -4
- package/src/store.js +21 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,111 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2 — 2026-10-23
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Nothing loads from the LocalMind repo any more. diskformer v0.6.4 (095ab20) holds LocalMind's whole-model
|
|
7
|
+
engines too (LFM2.5, Gemma 4 E2B/E4B, Ternary Bonsai 2 27B; MIT, confirmed by their author), and the `localmind`
|
|
8
|
+
back end loads them from diskformer's `engines/`. The id stays `localmind`. `PINS.localmind` and
|
|
9
|
+
`SOURCES.localmind` are gone.
|
|
10
|
+
- LFM2.5 230M through the `localmind` back end from diskformer's copy: the same reply as from LocalMind's commit
|
|
11
|
+
203288a (the engine file is byte-identical), on topic.
|
|
12
|
+
|
|
13
|
+
## 0.3.1 — 2026-10-23
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- MiniCPM-V 4.7 35B-A3B (openbmb/MiniCPM-V-4.7-35B-A3B-gguf, Q4_0, 19.7 GB) on the `diskformer` back end, through
|
|
17
|
+
diskformer's `MiniCpmV47Ssd`: text, and images through `ensureVision()` and `{ type: 'image', rgb, width, height }`
|
|
18
|
+
blocks. Floor 4 GB (Qwen3.6's; the smallest working budget is unmeasured); 14.1 tok/s over short continuations at a
|
|
19
|
+
4 GiB pool, ~6.1 GB on the GPU (LocalMind's gate). It ranks before `diskformer-llama`, which has no image path.
|
|
20
|
+
Not yet run through anyformer in a browser; LocalMind's live check is that check.
|
|
21
|
+
|
|
22
|
+
## 0.3.0 — 2026-10-23
|
|
23
|
+
|
|
24
|
+
What LocalMind's worker uses from its disk-tier engines, so its models can move onto anyformer with nothing lost.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- `generate()` chunks on the `diskformer` back end carry `token`, the engine's token id.
|
|
28
|
+
- `session.think`: `{ openId, closeId, inPrompt, preamble, always }`, where a model's reasoning starts and ends as
|
|
29
|
+
token ids (Gemma 4's thought channel, Qwen3.6 and Ling's `<think>`); null where the back end has none.
|
|
30
|
+
- `session.prime(system)`, where the engine has it (Qwen3.6 35B): checkpoints a system prompt after load.
|
|
31
|
+
- `session.ensureVision({ onProgress })`, where the engine has it; image blocks `{ type: 'image', rgb, width, height }`
|
|
32
|
+
pass through to such an engine. No vision model is in `DISKFORMER_MODELS` yet.
|
|
33
|
+
- `load(repo, { poolBytes })`: the expert cache size, passed to the engine; the budget then only admits the model.
|
|
34
|
+
- `load(repo, { localFile })`: a GGUF the user picked, ingested instead of downloaded.
|
|
35
|
+
|
|
36
|
+
## 0.2.5 — 2026-10-23
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- diskformer pinned to v0.6.3 (1d8b418): Ling 3.0 Flash loads a complete store without reading the parts' headers
|
|
40
|
+
from Hugging Face; the browser llama.cpp build is rebuilt (split GGUFs still refused there). Granite 4.0 H Tiny
|
|
41
|
+
through `diskformer-llama` on the new build: 9/9, chat path = reference. The hand engines anyformer gated are
|
|
42
|
+
unchanged (`qwen3_moe_ssd.js` only adds an export).
|
|
43
|
+
|
|
44
|
+
## 0.2.4 — 2026-10-23
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- The `diskformer` back end's `generate()` honours `thinking`, as `diskformer-llama` does since 0.2.1: off by default,
|
|
48
|
+
`{ thinking: true }` lets Gemma 4, Qwen3.6 or Ling 3.0 Flash reason first (the engines' `enableThinking`).
|
|
49
|
+
|
|
50
|
+
## 0.2.3 — 2026-10-23
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
- Ling 3.0 Flash (inclusionAI/Ling-3.0-flash-GGUF, two-part Q4_K_M, 77 GB) on the `diskformer` back end, through
|
|
54
|
+
diskformer's `LingMoeSsd` hand engine: from a 6.8 GB budget, 5.53 tok/s and an 80.7 GB OPFS store (LocalMind's gate).
|
|
55
|
+
Not yet run through anyformer in a browser: LocalMind's live check through anyformer is that check.
|
|
56
|
+
- `describe()` keeps the Hub's last answer per repo in Cache Storage (`anyformer-describe-v1`) and uses it when the
|
|
57
|
+
network fails, so a downloaded model loads offline.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
- diskformer pinned to v0.6.2 (f5f1177): adds `ling_moe_ssd.js` and two quant formats to `iq_quants.js`, which no
|
|
61
|
+
engine gated earlier imports; the earlier gates stand.
|
|
62
|
+
|
|
63
|
+
## 0.2.2 — 2026-10-23
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
- `load(repo, { file })` and `plan(repo, { file })`: load a chosen GGUF from the repo; a back end that cannot load
|
|
67
|
+
that file refuses with code `model`.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
- `diskformer-llama` loads the file diskformer checked when the repo has it (`CHECKED_FILES`, from diskformer's
|
|
71
|
+
catalog and coverage table), else the preferred quant. GLM-4.7-Flash now defaults to Q3_K_M (14.6 GB, gated 8/9 by
|
|
72
|
+
diskformer), not Q4_K_M (18.3 GB, not gated). Granite 4.0 H Tiny and Gemma 4 26B keep the files their gates used.
|
|
73
|
+
|
|
74
|
+
## 0.2.1 — 2026-10-23
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
- diskformer pinned to v0.6.1 (5bccf32): its llama.cpp build takes a thinking switch. `diskformer-llama`'s
|
|
78
|
+
`generate()` now turns thinking off by default (`thinking: true` turns it on), so a model that reasons first answers
|
|
79
|
+
directly, as on the other back ends.
|
|
80
|
+
|
|
81
|
+
### Measured (Chrome, M4 Pro; correctness only)
|
|
82
|
+
- Granite 4.0 H Tiny via `diskformer-llama`: 9/9; the chat path matches the reference.
|
|
83
|
+
- Gemma 4 26B-A4B via `diskformer-llama`: 8/9 (miss: system-pirate, as diskformer's own run); the chat path matches the
|
|
84
|
+
reference, with no thought block. The hand engines are unchanged in v0.6.1, so their 9/9 at 0.2.0 stands.
|
|
85
|
+
|
|
86
|
+
## 0.2.0 — 2026-10-22
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
- `diskformer-llama` back end: mixture-of-experts GGUFs through diskformer's llama.cpp path (`diskformer/llama`,
|
|
90
|
+
llama.cpp with WebGPU in a worker, experts paged from OPFS), for the 17 architectures diskformer has checked
|
|
91
|
+
(`LLAMA_ARCHITECTURES`). A diskformer hand-written engine still ranks first where it covers the model.
|
|
92
|
+
- `probe()` reports `jspi` (WebAssembly JSPI), which the llama.cpp build needs.
|
|
93
|
+
- `stored()` and `remove()` cover the llama.cpp path's GGUFs in OPFS (`gguf/`).
|
|
94
|
+
|
|
95
|
+
### Changed
|
|
96
|
+
- diskformer pinned to v0.6.0 (439ad4e) for both its hand engines and the llama.cpp path.
|
|
97
|
+
|
|
98
|
+
### Measured (Chrome, M4 Pro)
|
|
99
|
+
- Granite 4.0 H Tiny through `diskformer-llama`: 9/9 replies identical to diskformer's reference
|
|
100
|
+
(`npm run test:browser -- llama-gate`).
|
|
101
|
+
- Gemma 4 26B-A4B through `diskformer-llama`: 8/9 identical to llama.cpp b9830, the same 8/9 (miss: system-pirate)
|
|
102
|
+
diskformer records for its own Chrome run. The resolver still sends this repo to the hand engine.
|
|
103
|
+
- Gemma 4 26B-A4B on the hand engine at the new pin: 9/9 identical (`diskformer-chat`).
|
|
104
|
+
|
|
105
|
+
### Known gaps
|
|
106
|
+
- `diskformer-llama`'s chat path does not turn thinking off: a model that reasons first (Gemma 4, Qwen3.6, gpt-oss)
|
|
107
|
+
begins its reply with its thought block. Closed in 0.2.1.
|
|
108
|
+
|
|
3
109
|
## 0.1.0 — 2026-10-14
|
|
4
110
|
|
|
5
111
|
The first release with code. `anyformer@0.0.1` reserved the name and held none.
|
package/README.md
CHANGED
|
@@ -58,7 +58,8 @@ back end's answer: yes with an estimate (GPU bytes, disk bytes, tok/s when measu
|
|
|
58
58
|
| `kohra` | kohra, text-diffusion over onnxruntime-web | MDLM and BD3LM repos tagged `text-diffusion` |
|
|
59
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
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) |
|
|
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), Ling 3.0 Flash 124B (from 6.8 GB), MiniCPM-V 4.7 with images (from 4 GB) |
|
|
62
|
+
| `diskformer-llama` | diskformer's llama.cpp build with WebGPU, mixture-of-experts layers paged in from OPFS | GGUFs of the 17 MoE architectures diskformer has checked (Granite 4.0 H, gpt-oss, GLM-4.7-Flash, LFM2 MoE…); a hand engine above wins where it covers the model |
|
|
62
63
|
|
|
63
64
|
Each runtime loads from an exact version or commit in [src/sources.js](src/sources.js); set a back end's `.source`
|
|
64
65
|
(for example `kohraBackend.source`) to load another copy. `BACKENDS` lists the back ends `load()` asks by default: each joins after its browser test passes.
|
|
@@ -66,8 +67,7 @@ Each runtime loads from an exact version or commit in [src/sources.js](src/sourc
|
|
|
66
67
|
## When no back end can run it
|
|
67
68
|
|
|
68
69
|
`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)
|
|
70
|
+
reason. Codes are a closed set, each with one remedy in `REFUSALS`: `format`, `model`, `task`, `webgpu`, `f16`, `opfs`, `budget` (with `need`, the budget that would pass)
|
|
71
71
|
and `threw`. Every other failure is an `AnyformerError` too, with a code from `ERROR_CODES` (`LOAD_FAILED`,
|
|
72
72
|
`BUDGET_TOO_SMALL`, `ABORTED`, `EXECUTION_FAILED`, `INVALID_ARGUMENT`, `DISPOSED`); check it with
|
|
73
73
|
`isAnyformerError(e, code)`. A session runs one `generate()` at a time, in call order.
|
|
@@ -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, maxCtx, onProgress, signal }) // → { backend, task, plan, run(input), generate?, dispose() }
|
|
79
|
+
load(repo, { budget, task, file, maxCtx, poolBytes, localFile, onProgress, signal }) // → { backend, task, plan, run(input), generate?, think?, prime?, 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
|
|
@@ -107,6 +107,7 @@ npm run test:browser -- store # list a download, remove it
|
|
|
107
107
|
npm run test:browser -- chat --query "repo=LiquidAI/LFM2.5-1.2B-Instruct-GGUF" # a LocalMind engine
|
|
108
108
|
npm run test:browser -- vision --query "repo=onnx-community/Qwen3.5-4B-ONNX-OPT" # names the two cats
|
|
109
109
|
npm run test:browser -- diskformer-chat # Gemma 4 26B-A4B at 2.5 GB: 9/9 replies identical to llama.cpp b9830
|
|
110
|
+
npm run test:browser -- llama-gate --query "repo=ibm-granite/granite-4.0-h-tiny-GGUF&refs=diskformer-granite-refs" # 9/9 via diskformer-llama
|
|
110
111
|
```
|
|
111
112
|
|
|
112
113
|
The browser tests fail a reply that is off topic or repeats itself, and `diskformer-chat` fails any reply that differs
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { resolve, REFUSALS } from './src/resolve.js';
|
|
|
7
7
|
export { transformersBackend } from './src/backends/transformers.js';
|
|
8
8
|
export { kohraBackend } from './src/backends/kohra.js';
|
|
9
9
|
export { diskformerBackend, DISKFORMER_MODELS } from './src/backends/diskformer.js';
|
|
10
|
+
export { diskformerLlamaBackend, LLAMA_ARCHITECTURES } from './src/backends/diskformer-llama.js';
|
|
10
11
|
export { TOOLS, call, registerTools, journal, toolOptions } from './src/tools.js';
|
|
11
12
|
export { MODELS } from './src/models.js';
|
|
12
13
|
export { stored, remove } from './src/store.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anyformer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// diskformer-llama: mixture-of-experts GGUFs through diskformer's llama.cpp path (diskformer/llama: llama.cpp with
|
|
2
|
+
// WebGPU in a worker, the experts paged in from OPFS on demand). One engine for every architecture diskformer has
|
|
3
|
+
// checked in Chrome, so a new MoE model of a covered architecture needs no anyformer code. Where diskformer also has a
|
|
4
|
+
// hand-written engine for the model (the diskformer back end), that one carries a measured speed and ranks first.
|
|
5
|
+
// llama.js loads from the CDN at load() time, at the commit src/sources.js pins; set diskformerLlamaBackend.source to
|
|
6
|
+
// load another copy.
|
|
7
|
+
|
|
8
|
+
import { AnyformerError } from '../errors.js';
|
|
9
|
+
import { SOURCES } from '../sources.js';
|
|
10
|
+
|
|
11
|
+
// Architectures that pass diskformer's two checks (docs/coverage.md at the pinned commit): paging is exact natively,
|
|
12
|
+
// and the Chrome replies are compared with llama.cpp. Other models of the same architecture are expected to run.
|
|
13
|
+
// Left out: hy_v3 and afmoe (run, below diskformer's bar), laguna (unknown tokens), dense architectures (no experts
|
|
14
|
+
// to page), and Mixtral-style `llama` MoE (runs, too slow to use).
|
|
15
|
+
export const LLAMA_ARCHITECTURES = [
|
|
16
|
+
'gemma4', 'qwen3moe', 'qwen3next', 'qwen35moe', 'qwen3vlmoe', 'deepseek2', 'nemotron_h_moe', 'ernie4_5-moe',
|
|
17
|
+
'gpt-oss', 'cohere2moe', 'lfm2moe', 'mellum', 'bailingmoe2', 'granitehybrid', 'bailingmoe3', 'flint', 'olmoe',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Quant preference by the token in the file name; the first one the repo has wins. The files diskformer checked are
|
|
21
|
+
// Q4_K_M, Q4_0, Q4_1, MXFP4 and Q3_K_M.
|
|
22
|
+
const QUANTS = ['Q4_K_M', 'Q4_0', 'Q4_1', 'MXFP4', 'Q3_K_M', 'Q4_K_S', 'Q5_K_M', 'Q5_K_S', 'Q6_K', 'Q8_0'];
|
|
23
|
+
|
|
24
|
+
// The exact files diskformer checked (ggml/chat.html CATALOG and docs/coverage.md at the pinned commit). When a repo
|
|
25
|
+
// holds one, it is the default: the coverage numbers were measured on that file.
|
|
26
|
+
export const CHECKED_FILES = [
|
|
27
|
+
'gemma-4-26B_q4_0-it.gguf', 'Qwen3-30B-A3B-Instruct-2507-Q3_K_M.gguf', 'Qwen3-Next-80B-A3B-Instruct-Q4_K_M.gguf',
|
|
28
|
+
'Qwen3.6-35B-A3B-UD-Q3_K_M.gguf', 'Qwen3-VL-30B-A3B-Instruct-Q3_K_M.gguf', 'GLM-4.7-Flash-Q3_K_M.gguf',
|
|
29
|
+
'NVIDIA-Nemotron-3-Nano-30B-A3B-Q4_K_M.gguf', 'ERNIE-4.5-21B-A3B-Thinking-Q4_K_M.gguf', 'gpt-oss-20b-MXFP4.gguf',
|
|
30
|
+
'North-Mini-Code-1.0-UD-Q3_K_M.gguf', 'LFM2.5-24B-A2B-QAD-Q4_0.gguf', 'LFM2-8B-A1B-Q4_K_M.gguf',
|
|
31
|
+
'Mellum2-12B-A2.5B-Instruct-Q4_K_M.gguf', 'Ling-mini-2.0-Q4_K_M.gguf', 'granite-4.0-h-tiny-Q4_K_M.gguf',
|
|
32
|
+
'Ling-3.0-tiny-Q4_K_M.gguf', 'flint-int4-q4_1.gguf', 'OLMoE-1B-7B-0125-Instruct-Q4_K_M.gguf',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// The GGUF to load: the language model (no mmproj vision tower), one file (no split sets). The caller's file when
|
|
36
|
+
// given; else the file diskformer checked; else the preferred quant.
|
|
37
|
+
export function pickGguf(model, file = null) {
|
|
38
|
+
const lms = model.files.gguf.filter((f) => !/mmproj/i.test(f.name) && !/-\d{5}-of-\d{5}\.gguf$/.test(f.name));
|
|
39
|
+
if (file) return lms.find((f) => f.name === file) || null;
|
|
40
|
+
const checked = lms.find((f) => CHECKED_FILES.includes(f.name.split('/').pop()));
|
|
41
|
+
if (checked) return checked;
|
|
42
|
+
for (const q of QUANTS) {
|
|
43
|
+
const f = lms.find((x) => new RegExp(`(^|[-_.])${q}([-_.]|$)`, 'i').test(x.name.replace(/\.gguf$/i, '')));
|
|
44
|
+
if (f) return f;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// OPFS keeps llama.cpp's GGUFs by file name (gguf/<name>), with no repo. anyformer records name → repo here, so
|
|
50
|
+
// stored() can say which repo a file belongs to.
|
|
51
|
+
export const LLAMA_INDEX = 'anyformer-llama.json';
|
|
52
|
+
async function readIndex() {
|
|
53
|
+
try { return JSON.parse(await (await (await (await navigator.storage.getDirectory()).getFileHandle(LLAMA_INDEX)).getFile()).text()); } catch { return {}; }
|
|
54
|
+
}
|
|
55
|
+
async function writeIndex(index) {
|
|
56
|
+
const h = await (await navigator.storage.getDirectory()).getFileHandle(LLAMA_INDEX, { create: true });
|
|
57
|
+
const w = await h.createWritable();
|
|
58
|
+
await w.write(JSON.stringify(index));
|
|
59
|
+
await w.close();
|
|
60
|
+
}
|
|
61
|
+
export { readIndex as readLlamaIndex };
|
|
62
|
+
|
|
63
|
+
// Chat content: a string, or text blocks joined. llama.cpp's chat here takes text only.
|
|
64
|
+
function textOnly(messages) {
|
|
65
|
+
return messages.map((m) => {
|
|
66
|
+
if (typeof m.content === 'string') return m;
|
|
67
|
+
const other = m.content.find((b) => b.type !== 'text');
|
|
68
|
+
if (other) throw new AnyformerError('INVALID_ARGUMENT', `diskformer-llama runs text only; got a ${other.type} block`);
|
|
69
|
+
return { ...m, content: m.content.map((b) => b.text).join('\n') };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const diskformerLlamaBackend = {
|
|
74
|
+
id: 'diskformer-llama',
|
|
75
|
+
tasks: ['text-generation', 'image-text-to-text'],
|
|
76
|
+
source: SOURCES.diskformerLlama,
|
|
77
|
+
// Where a GGUF downloads from; a test points it at a local server.
|
|
78
|
+
url: (model, file) => `${model.endpoint || 'https://huggingface.co'}/${model.repo}/resolve/${model.revision || 'main'}/${file.name.split('/').map(encodeURIComponent).join('/')}`,
|
|
79
|
+
|
|
80
|
+
canRun(model, caps, { budget, task, file = null }) {
|
|
81
|
+
if (!model.formats.includes('gguf')) return { ok: false, code: 'format', reason: 'no GGUF in the repo' };
|
|
82
|
+
if (task && !diskformerLlamaBackend.tasks.includes(task)) return { ok: false, code: 'task', reason: `task ${task}; diskformer's llama.cpp path runs text generation only` };
|
|
83
|
+
if (!LLAMA_ARCHITECTURES.includes(model.architecture)) return { ok: false, code: 'model', reason: `architecture ${model.architecture || 'unknown'} is not one diskformer's llama.cpp path has checked (${LLAMA_ARCHITECTURES.join(', ')})` };
|
|
84
|
+
const f = pickGguf(model, file);
|
|
85
|
+
if (!f) return { ok: false, code: 'model', reason: file ? `${file} is not a single-file language-model GGUF in the repo` : `no single-file GGUF at ${QUANTS.join(', ')} in the repo` };
|
|
86
|
+
if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
|
|
87
|
+
if (!caps.memory64 || !caps.jspi) return { ok: false, code: 'webgpu', reason: 'the llama.cpp build needs wasm memory64 and JSPI (Chrome or Edge 137+)' };
|
|
88
|
+
if (!caps.opfs) return { ok: false, code: 'opfs', reason: 'no OPFS (navigator.storage.getDirectory)' };
|
|
89
|
+
// The budget is not checked here: llama.cpp plans its expert slots from it at load time, and refuses there.
|
|
90
|
+
return { ok: true, estimate: { gpuBytes: budget, diskBytes: f.size, tokPerSec: null } };
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// maxCtx sizes the KV cache; 1024 is the context diskformer's gates run at.
|
|
94
|
+
async load(model, { budget, file = null, maxCtx = 1024, onProgress, signal }) {
|
|
95
|
+
const f = pickGguf(model, file);
|
|
96
|
+
const { Llama } = await import(diskformerLlamaBackend.source);
|
|
97
|
+
const llama = new Llama();
|
|
98
|
+
const say = (e) => onProgress?.({ backend: 'diskformer-llama', file: f.name, ...e });
|
|
99
|
+
const stop = () => llama.abort();
|
|
100
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
101
|
+
let info;
|
|
102
|
+
try {
|
|
103
|
+
const have = (await llama.list()).find((m) => m.name === f.name && !m.partial);
|
|
104
|
+
if (!have || have.size !== f.size) {
|
|
105
|
+
say({ status: 'download', loaded: 0, total: f.size });
|
|
106
|
+
await llama.download({ url: diskformerLlamaBackend.url(model, f), name: f.name, size: f.size },
|
|
107
|
+
(p) => say({ status: 'download', loaded: p.copied, total: p.total }));
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
}
|
|
110
|
+
await writeIndex({ ...(await readIndex()), [f.name]: model.repo });
|
|
111
|
+
say({ status: 'load' });
|
|
112
|
+
info = await llama.load({ name: f.name, budget, ctx: maxCtx }, (p) => say({ status: 'plan', plan: p }));
|
|
113
|
+
signal?.throwIfAborted();
|
|
114
|
+
} catch (e) {
|
|
115
|
+
llama.terminate();
|
|
116
|
+
throw e;
|
|
117
|
+
} finally {
|
|
118
|
+
signal?.removeEventListener('abort', stop);
|
|
119
|
+
}
|
|
120
|
+
say({ status: 'ready', slots: info.slots });
|
|
121
|
+
return {
|
|
122
|
+
task: 'text-generation',
|
|
123
|
+
llama,
|
|
124
|
+
info,
|
|
125
|
+
// llama.cpp hands back text pieces, not token ids, so there are no think ids to report.
|
|
126
|
+
think: null,
|
|
127
|
+
// thinking: false (the default, as the other engines here) renders the template with thinking off, so a model that
|
|
128
|
+
// reasons first answers directly; true lets it think.
|
|
129
|
+
async *generate(messages, { maxNewTokens = 512, signal, thinking = false } = {}) {
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let wake = null, finished = false, failed = null, result = null, text = '', tokens = 0;
|
|
132
|
+
const nudge = () => { wake?.(); wake = null; };
|
|
133
|
+
const abort = () => llama.abort();
|
|
134
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
135
|
+
const job = llama.chat(textOnly(messages), {
|
|
136
|
+
n: maxNewTokens, thinking,
|
|
137
|
+
onPiece: (s) => { text += s; tokens++; chunks.push({ text, tokens }); nudge(); },
|
|
138
|
+
}).then((r) => { result = r; }, (e) => { failed = e; }).finally(() => { finished = true; nudge(); });
|
|
139
|
+
try {
|
|
140
|
+
for (;;) {
|
|
141
|
+
while (chunks.length) yield chunks.shift();
|
|
142
|
+
if (finished) break;
|
|
143
|
+
await new Promise((r) => { wake = r; });
|
|
144
|
+
}
|
|
145
|
+
signal?.throwIfAborted();
|
|
146
|
+
if (failed) throw failed;
|
|
147
|
+
const s = result.stats || {};
|
|
148
|
+
const decode = s.decode_tokens && s.decode_ms ? s.decode_tokens / (s.decode_ms / 1000) : null;
|
|
149
|
+
yield { text: result.text, tokens: s.decode_tokens ?? tokens, ...(decode && { tokPerSec: decode }) };
|
|
150
|
+
} finally {
|
|
151
|
+
signal?.removeEventListener('abort', abort);
|
|
152
|
+
// The caller stopped reading early: stop the reply so the next call does not queue behind it.
|
|
153
|
+
if (!finished) { llama.abort(); await job; }
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
// A continuation of an already formatted prompt, greedy: what diskformer's gate replays.
|
|
157
|
+
complete: (prompt, { maxNewTokens = 64 } = {}) => llama.complete(prompt, { n: maxNewTokens }),
|
|
158
|
+
dispose: () => llama.terminate(),
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
};
|
|
@@ -18,6 +18,28 @@ export const DISKFORMER_MODELS = [
|
|
|
18
18
|
engine: 'qwen35_moe_ssd.js', Engine: 'Qwen35MoeSsd', minBudget: 4e9,
|
|
19
19
|
speeds: [[4e9, 9.2]],
|
|
20
20
|
},
|
|
21
|
+
// Qwen3.6's language model with a vision encoder (mmproj, ~1.1 GB, loaded on the first image by ensureVision).
|
|
22
|
+
// From LocalMind's gate (plan/2026-10-14-minicpm-harness, 4 GiB pool, context 512): ~6.1 GB on the GPU (dense 1.59,
|
|
23
|
+
// pool 4.29), 14.1 tok/s over short greedy continuations. The smallest working budget is unmeasured; the floor is
|
|
24
|
+
// Qwen3.6's, the same language-model shape. Only this engine reads its images, and it ranks before diskformer-llama.
|
|
25
|
+
{
|
|
26
|
+
repo: 'openbmb/MiniCPM-V-4.7-35B-A3B-gguf', file: 'MiniCPM-V-4_7-35B-A3B-Q4_0.gguf', size: 19714550688,
|
|
27
|
+
engine: 'qwen35_moe_ssd.js', Engine: 'MiniCpmV47Ssd', minBudget: 4e9,
|
|
28
|
+
speeds: [[6.1e9, 14.1]],
|
|
29
|
+
},
|
|
30
|
+
// Two parts, ingested as one file by the engine's splitFetch (engines/ling_moe_ssd.js LING_FLASH, rev 29edce5).
|
|
31
|
+
// Budget and speed from LocalMind's gate (plan/2026-10-22-ling-harness/results.md): 2.51 GB of dense weights on
|
|
32
|
+
// the GPU plus a 4.29 GB expert pool, 5.53 tok/s decode; the OPFS store is 80.7 GB.
|
|
33
|
+
{
|
|
34
|
+
repo: 'inclusionAI/Ling-3.0-flash-GGUF',
|
|
35
|
+
parts: [
|
|
36
|
+
{ file: 'Q4_K_M/Ling-3.0-flash-Q4_K_M-00001-of-00002.gguf', size: 49995534208 },
|
|
37
|
+
{ file: 'Q4_K_M/Ling-3.0-flash-Q4_K_M-00002-of-00002.gguf', size: 27014610464 },
|
|
38
|
+
],
|
|
39
|
+
size: 49995534208 + 27014610464, storeBytes: 80.7e9,
|
|
40
|
+
engine: 'ling_moe_ssd.js', Engine: 'LingMoeSsd', minBudget: 6.8e9,
|
|
41
|
+
speeds: [[6.8e9, 5.53]],
|
|
42
|
+
},
|
|
21
43
|
];
|
|
22
44
|
|
|
23
45
|
// diskformer's engines refuse a budget with a plain Error ("GPU budget 2.50 GB is too small: … need 3.04 GB, and the
|
|
@@ -27,8 +49,9 @@ function budgetError(e) {
|
|
|
27
49
|
return m && new AnyformerError('BUDGET_TOO_SMALL', `diskformer: ${e.message}`, { cause: e, need: Math.ceil((+m[1] + +m[2]) * 1e9) });
|
|
28
50
|
}
|
|
29
51
|
|
|
52
|
+
const has = (model, file, size) => model.files.gguf.some((f) => f.name === file && f.size === size);
|
|
30
53
|
const match = (model) => DISKFORMER_MODELS.find((m) => m.repo === model.repo &&
|
|
31
|
-
|
|
54
|
+
(m.parts ? m.parts.every((p) => has(model, p.file, p.size)) : has(model, m.file, m.size)));
|
|
32
55
|
|
|
33
56
|
// The speed measured at the largest budget not above this one.
|
|
34
57
|
const speedAt = (m, budget) => m.speeds.filter(([b]) => b <= budget).at(-1)?.[1] ?? null;
|
|
@@ -40,40 +63,61 @@ export const diskformerBackend = {
|
|
|
40
63
|
// Gemma 4's repo is tagged image-text-to-text; the engines run its text turns.
|
|
41
64
|
tasks: ['text-generation', 'image-text-to-text'],
|
|
42
65
|
source: SOURCES.diskformer,
|
|
66
|
+
// Where the engine downloads its GGUF from; null is Hugging Face. A test points it at a local server.
|
|
67
|
+
url: null,
|
|
43
68
|
|
|
44
69
|
canRun(model, caps, { budget, task }) {
|
|
45
70
|
if (!model.formats.includes('gguf')) return { ok: false, code: 'format', reason: 'no GGUF in the repo' };
|
|
46
71
|
if (task && !diskformerBackend.tasks.includes(task)) return { ok: false, code: 'task', reason: `task ${task}; diskformer engines run text generation only` };
|
|
47
72
|
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.
|
|
73
|
+
if (!m) return { ok: false, code: 'model', reason: `no diskformer engine for this GGUF; engines cover ${DISKFORMER_MODELS.map((x) => x.repo).join(', ')}` };
|
|
49
74
|
if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
|
|
50
75
|
if (!caps.f16) return { ok: false, code: 'f16', reason: 'the GPU lacks shader-f16' };
|
|
51
76
|
if (!caps.opfs) return { ok: false, code: 'opfs', reason: 'no OPFS (navigator.storage.getDirectory)' };
|
|
52
77
|
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
78
|
// 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) } };
|
|
79
|
+
return { ok: true, estimate: { gpuBytes: budget, diskBytes: m.storeBytes ?? m.size, tokPerSec: speedAt(m, budget) } };
|
|
55
80
|
},
|
|
56
81
|
|
|
57
82
|
// maxCtx sizes the KV cache, which stays on the GPU: the 2.5 GB minimum and the speeds above hold at 1024 tokens,
|
|
58
83
|
// the context diskformer's chat gate runs at. A longer context needs a larger budget.
|
|
59
|
-
|
|
84
|
+
// poolBytes: the expert cache size, passed to the engine; the budget then only admits the model (the engine sizes
|
|
85
|
+
// its pool from gpuBudgetBytes when given one, so it gets one or the other).
|
|
86
|
+
// localFile: the GGUF as a File the user picked; the engine ingests it instead of downloading, and refuses a file
|
|
87
|
+
// whose size differs from the one it is pinned to.
|
|
88
|
+
async load(model, { budget, maxCtx = 1024, poolBytes, localFile, onProgress, signal }) {
|
|
60
89
|
const m = match(model);
|
|
61
90
|
const mod = await import(`${diskformerBackend.source}/${m.engine}`);
|
|
62
91
|
let engine;
|
|
63
92
|
try {
|
|
64
93
|
engine = await mod[m.Engine].load(null, {
|
|
65
94
|
// 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',
|
|
95
|
+
...(poolBytes ? { poolBytes } : { gpuBudgetBytes: budget }), maxCtx, signal, root: 'localmind-ssd',
|
|
96
|
+
...(localFile && { localFile }),
|
|
97
|
+
...(diskformerBackend.url && (m.parts ? { urls: m.parts.map((p) => diskformerBackend.url(model, { ...m, file: p.file })) } : { url: diskformerBackend.url(model, m) })),
|
|
67
98
|
onProgress: onProgress && ((e) => onProgress({ backend: 'diskformer', ...e })),
|
|
68
99
|
});
|
|
69
100
|
} catch (e) { throw budgetError(e) || e; }
|
|
101
|
+
// Where the model's reasoning starts and ends, as token ids, so a caller can split it out without parsing text.
|
|
102
|
+
const open = engine.thinkOpenTokenId ?? null;
|
|
70
103
|
return {
|
|
71
104
|
task: 'text-generation',
|
|
72
105
|
engine,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
106
|
+
think: open === null ? null : {
|
|
107
|
+
openId: open, closeId: engine.thinkCloseTokenId ?? null, inPrompt: engine.thinkInPrompt ?? null,
|
|
108
|
+
preamble: engine.thinkPreamble ?? null, always: engine.alwaysThinks ?? null,
|
|
109
|
+
},
|
|
110
|
+
// thinking: false (the default, as on every back end here) answers directly; true lets a model that reasons
|
|
111
|
+
// (Gemma 4, Qwen3.6, Ling) think first. Image blocks ({ type: 'image', rgb, width, height }) pass through to
|
|
112
|
+
// an engine that reads them.
|
|
113
|
+
async *generate(messages, { maxNewTokens = 512, signal, thinking = false } = {}) {
|
|
114
|
+
for await (const r of engine.generate(messages, { maxNewTokens, signal, enableThinking: thinking === true }))
|
|
115
|
+
yield { text: r.text, token: r.token, tokens: r.tokens };
|
|
76
116
|
},
|
|
117
|
+
// Checkpoints a system prompt after load, so the first question does not wait for it (Qwen3.6, MiniCPM-V).
|
|
118
|
+
...(typeof engine.prime === 'function' && { prime: (system) => engine.prime(system) }),
|
|
119
|
+
// Loads the vision tower, for an engine that reads images (MiniCPM-V).
|
|
120
|
+
...(typeof engine.ensureVision === 'function' && { ensureVision: (o) => engine.ensureVision(o) }),
|
|
77
121
|
dispose: () => engine.dispose(),
|
|
78
122
|
};
|
|
79
123
|
},
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// LocalMind's hand-written WebGPU engines: whole model resident on the GPU, kernels written per architecture. Each
|
|
2
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()
|
|
4
|
-
// localmindBackend.source to load another copy.
|
|
3
|
+
// not only the one LocalMind ships. They live in diskformer (engines/, since v0.6.4) and load from the CDN at load()
|
|
4
|
+
// time, at the commit src/sources.js pins; set localmindBackend.source to load another copy. The id stays 'localmind',
|
|
5
|
+
// the name callers and stored() already use, though no code loads from LocalMind's repo any more.
|
|
5
6
|
//
|
|
6
7
|
// lfm2 src/lfm2_5.js GGUF, general.architecture 'lfm2' (dense LFM2 / LFM2.5), Q4_0 Q8_0 Q4_K Q5_K Q6_K
|
|
7
8
|
// gemma4 src/gemma-4-e2b.js Google's QAT-mobile safetensors export of Gemma 4 (E2B, E4B): config.json-driven
|
|
@@ -58,7 +59,7 @@ function match(model) {
|
|
|
58
59
|
export const localmindBackend = {
|
|
59
60
|
id: 'localmind',
|
|
60
61
|
tasks: ['text-generation'],
|
|
61
|
-
source: SOURCES.
|
|
62
|
+
source: SOURCES.diskformer,
|
|
62
63
|
|
|
63
64
|
canRun(model, caps, { budget, task }) {
|
|
64
65
|
const m = match(model);
|
package/src/describe.js
CHANGED
|
@@ -3,11 +3,24 @@
|
|
|
3
3
|
|
|
4
4
|
const HF = 'https://huggingface.co';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// The last answer the Hub gave for each URL, kept in Cache Storage so a model already downloaded still loads offline.
|
|
7
|
+
export const DESCRIBE_CACHE = 'anyformer-describe-v1';
|
|
8
|
+
|
|
9
|
+
export async function describe(repo, { fetch = globalThis.fetch, revision = 'main', endpoint = HF, caches = globalThis.caches } = {}) {
|
|
7
10
|
const url = `${endpoint}/api/models/${repo}${revision === 'main' ? '' : `/revision/${revision}`}?blobs=true`;
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
const store = caches ? await caches.open(DESCRIBE_CACHE).catch(() => null) : null;
|
|
12
|
+
let res, failed;
|
|
13
|
+
try { res = await fetch(url); } catch (e) { failed = e; }
|
|
14
|
+
if (res?.ok) {
|
|
15
|
+
const info = await res.json();
|
|
16
|
+
await store?.put(url, new Response(JSON.stringify(info), { headers: { 'Content-Type': 'application/json' } })).catch(() => {});
|
|
17
|
+
return fromHubInfo(info, { endpoint });
|
|
18
|
+
}
|
|
19
|
+
// No network, or the Hub failed: the cached answer, when this browser has one.
|
|
20
|
+
const hit = await store?.match(url).catch(() => null);
|
|
21
|
+
if (hit) return fromHubInfo(await hit.json(), { endpoint });
|
|
22
|
+
throw failed ? new Error(`describe ${repo}: ${url} failed: ${failed.message}`, { cause: failed })
|
|
23
|
+
: new Error(`describe ${repo}: ${url} returned ${res.status}`);
|
|
11
24
|
}
|
|
12
25
|
|
|
13
26
|
// Pure: Hub API JSON → descriptor. Exported for tests and for callers that already hold the JSON.
|
package/src/load.js
CHANGED
|
@@ -4,12 +4,15 @@ import { resolve } from './resolve.js';
|
|
|
4
4
|
import { transformersBackend } from './backends/transformers.js';
|
|
5
5
|
import { kohraBackend } from './backends/kohra.js';
|
|
6
6
|
import { diskformerBackend } from './backends/diskformer.js';
|
|
7
|
+
import { diskformerLlamaBackend } from './backends/diskformer-llama.js';
|
|
7
8
|
import { localmindBackend } from './backends/localmind.js';
|
|
8
9
|
import { wllamaBackend } from './backends/wllama.js';
|
|
9
10
|
import { AnyformerError, wrap } from './errors.js';
|
|
10
11
|
|
|
11
|
-
// Back ends land here as each one passes its browser test (test/browser/). The list order breaks ties
|
|
12
|
-
|
|
12
|
+
// Back ends land here as each one passes its browser test (test/browser/). The list order breaks ties: a
|
|
13
|
+
// diskformer hand engine carries a measured speed and outranks diskformer-llama for the same model; diskformer-llama
|
|
14
|
+
// comes before wllama because it pages experts instead of holding the whole model on the GPU.
|
|
15
|
+
export const BACKENDS = [transformersBackend, kohraBackend, diskformerBackend, localmindBackend, diskformerLlamaBackend, wllamaBackend];
|
|
13
16
|
|
|
14
17
|
// 2.5 GB: the smallest budget diskformer's chat gate passes at. Browsers do not report GPU memory, so a caller that
|
|
15
18
|
// knows more passes { budget }.
|
|
@@ -23,28 +26,29 @@ function noBackend(plan) {
|
|
|
23
26
|
|
|
24
27
|
// What load() would do, without loading: the descriptor, the caps, and every back end's answer.
|
|
25
28
|
// task: a Hugging Face pipeline task ('text-generation', 'feature-extraction', …); null runs the repo's own.
|
|
29
|
+
// file: a weights file in the repo to load (a GGUF name); null lets each back end pick.
|
|
26
30
|
// repo: a Hub id, or an entry from MODELS (its task and budget apply unless opts set them).
|
|
27
31
|
const entry = (repo, opts) => (typeof repo === 'object' && repo?.repo
|
|
28
32
|
? [repo.repo, { ...(repo.task && { task: repo.task }), ...(repo.budget && { budget: repo.budget }), ...opts }]
|
|
29
33
|
: [repo, opts]);
|
|
30
34
|
|
|
31
35
|
export async function plan(repoOrEntry, opts = {}) {
|
|
32
|
-
let [repo, { budget = DEFAULT_BUDGET, task = null, backends = BACKENDS, caps, model, fetch }] = entry(repoOrEntry, opts);
|
|
36
|
+
let [repo, { budget = DEFAULT_BUDGET, task = null, file = null, backends = BACKENDS, caps, model, fetch }] = entry(repoOrEntry, opts);
|
|
33
37
|
caps ??= await probe();
|
|
34
38
|
model ??= await describe(repo, fetch ? { fetch } : {});
|
|
35
|
-
return { model, caps, budget, task, ...(await resolve(model, caps, backends, { budget, task })) };
|
|
39
|
+
return { model, caps, budget, task, file, ...(await resolve(model, caps, backends, { budget, task, file })) };
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export async function load(repoOrEntry, options = {}) {
|
|
39
43
|
const [repo, opts] = entry(repoOrEntry, options);
|
|
40
|
-
const { backends = BACKENDS, maxCtx, dtype, onProgress, signal } = opts;
|
|
44
|
+
const { backends = BACKENDS, maxCtx, dtype, poolBytes, localFile, onProgress, signal } = opts;
|
|
41
45
|
let p;
|
|
42
46
|
try { p = await plan(repo, opts); } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${repo}: `); }
|
|
43
47
|
if (!p.chosen) throw noBackend(p);
|
|
44
48
|
const backend = backends.find((b) => b.id === p.chosen);
|
|
45
49
|
let session;
|
|
46
50
|
try {
|
|
47
|
-
session = await backend.load(p.model, { budget: p.budget, task: p.task, caps: p.caps, maxCtx, dtype, 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, onProgress, signal });
|
|
48
52
|
} catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${backend.id}: `); }
|
|
49
53
|
return { backend: backend.id, task: session.task || p.task || p.model.task || null, ...(session.device && { device: session.device }), plan: p, ...serialize(session) };
|
|
50
54
|
}
|
|
@@ -80,8 +84,25 @@ function serialize(session) {
|
|
|
80
84
|
release();
|
|
81
85
|
}
|
|
82
86
|
};
|
|
87
|
+
// A call that waits its turn like generate(): prime and ensureVision share the engine with it.
|
|
88
|
+
const queued = (fn) => async (...args) => {
|
|
89
|
+
if (disposed) throw gone();
|
|
90
|
+
const release = await turn();
|
|
91
|
+
try {
|
|
92
|
+
if (disposed) throw gone();
|
|
93
|
+
return await fn(...args);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
throw wrap(e, 'EXECUTION_FAILED');
|
|
96
|
+
} finally {
|
|
97
|
+
release();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
83
100
|
return {
|
|
84
101
|
...(generate && { generate }),
|
|
102
|
+
// Where reasoning starts and ends as token ids ({ openId, closeId, inPrompt, preamble, always }), or null.
|
|
103
|
+
...('think' in session && { think: session.think }),
|
|
104
|
+
...(session.prime && { prime: queued(session.prime) }),
|
|
105
|
+
...(session.ensureVision && { ensureVision: queued(session.ensureVision) }),
|
|
85
106
|
// The one call every task has. For text generation it is the last chunk of generate().
|
|
86
107
|
async run(input, o = {}) {
|
|
87
108
|
if (generate) { let last; for await (const c of generate(input, o)) last = c; return last; }
|
package/src/probe.js
CHANGED
|
@@ -14,6 +14,8 @@ export async function probe({ gpu = globalThis.navigator?.gpu, storage = globalT
|
|
|
14
14
|
maxBufferSize: 0, maxStorageBufferBindingSize: 0, adapter: null,
|
|
15
15
|
opfs: false, quotaBytes: 0, usageBytes: 0,
|
|
16
16
|
memory64: memory64(),
|
|
17
|
+
// JSPI: wasm that suspends on a promise (diskformer's llama.cpp build reads experts from OPFS this way).
|
|
18
|
+
jspi: typeof globalThis.WebAssembly?.Suspending === 'function',
|
|
17
19
|
};
|
|
18
20
|
const adapter = gpu ? await gpu.requestAdapter({ powerPreference: 'high-performance' }).catch(() => null) : null;
|
|
19
21
|
if (adapter) {
|
package/src/resolve.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Back-end contract (each back end is a plain object):
|
|
4
4
|
// id string, stable, e.g. 'diskformer'
|
|
5
5
|
// tasks the Hugging Face pipeline tasks it runs, e.g. ['text-generation']
|
|
6
|
-
// canRun(model, caps, { budget, task }) → { ok: true, estimate } | { ok: false, code, reason, need? }
|
|
6
|
+
// canRun(model, caps, { budget, task, file }) → { ok: true, estimate } | { ok: false, code, reason, need? }
|
|
7
|
+
// file: a weights file the caller chose (a GGUF name); a back end that cannot load that file refuses.
|
|
7
8
|
// estimate = { gpuBytes, diskBytes, tokPerSec | null }. No network, no GPU allocation: it reads the
|
|
8
9
|
// descriptor and caps only. code is one of REFUSALS below; need is the budget that would pass, with
|
|
9
10
|
// code 'budget'. A throw counts as code 'threw' with the message as the reason.
|
|
@@ -26,11 +27,11 @@ export const REFUSALS = {
|
|
|
26
27
|
threw: 'the back end crashed deciding; a bug in that back end, report it with the reason',
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
export async function resolve(model, caps, backends, { budget, task = null } = {}) {
|
|
30
|
+
export async function resolve(model, caps, backends, { budget, task = null, file = null } = {}) {
|
|
30
31
|
const candidates = [];
|
|
31
32
|
for (const [order, b] of backends.entries()) {
|
|
32
33
|
let r;
|
|
33
|
-
try { r = await b.canRun(model, caps, { budget, task }); } catch (e) { r = { ok: false, code: 'threw', reason: `canRun threw: ${e.message}` }; }
|
|
34
|
+
try { r = await b.canRun(model, caps, { budget, task, file }); } catch (e) { r = { ok: false, code: 'threw', reason: `canRun threw: ${e.message}` }; }
|
|
34
35
|
candidates.push(r.ok ? { backend: b.id, order, ok: true, estimate: r.estimate } : { backend: b.id, order, ok: false, code: r.code || 'model', reason: r.reason, ...(r.need && { need: r.need }) });
|
|
35
36
|
}
|
|
36
37
|
const ranked = candidates.filter((c) => c.ok).sort(rank);
|
package/src/sources.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Where each back end's runtime loads from: exact versions and commits, so a given anyformer behaves the same tomorrow.
|
|
2
|
-
// kohra
|
|
2
|
+
// kohra and diskformer move fast on their main branches; `node scripts/pin-sources.mjs` moves these pins
|
|
3
3
|
// to each repo's current main, and the browser tests then prove the new pins (test/browser/README.md).
|
|
4
4
|
// transformers.js and wllama move only by hand, in step with LocalMind's pins (plan/history.md, 2026-10-08).
|
|
5
5
|
|
|
@@ -7,8 +7,9 @@ export const PINS = {
|
|
|
7
7
|
transformers: '4.3.1',
|
|
8
8
|
wllama: '3.4.1', // LocalMind's pin; its stream-drain workaround (src/backends/wllama.js) is for this version
|
|
9
9
|
kohra: 'f678fac7342c79b58b640540b8b3a7d078b7ab1a',
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// v0.6.4: every engine (the disk-tier MoE engines, Ling 3.0 Flash, and LocalMind's whole-model engines LFM2.5,
|
|
11
|
+
// Gemma 4 E2B/E4B and Ternary Bonsai 2) under engines/, and the llama.cpp path under ggml/.
|
|
12
|
+
diskformer: '095ab2058be9de56841c51b9369ba556d625392b',
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
const gh = (repo, sha, path) => `https://cdn.jsdelivr.net/gh/NakliTechie/${repo}@${sha}/${path}`;
|
|
@@ -18,5 +19,5 @@ export const SOURCES = {
|
|
|
18
19
|
wllama: `https://cdn.jsdelivr.net/npm/@wllama/wllama@${PINS.wllama}/esm`,
|
|
19
20
|
kohra: gh('kohra', PINS.kohra, 'kohra.js'),
|
|
20
21
|
diskformer: gh('diskformer.js', PINS.diskformer, 'engines'),
|
|
21
|
-
|
|
22
|
+
diskformerLlama: gh('diskformer.js', PINS.diskformer, 'ggml/llama.js'),
|
|
22
23
|
};
|
package/src/store.js
CHANGED
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
// system-prompt snapshot).
|
|
11
11
|
// OPFS 'localmind-ssd/<key>/' (diskformer's MoE stores, Gemma 4's PLE table) with manifest.json naming the
|
|
12
12
|
// source; 'cache/' (wllama: '__metadata__<name>' names each file's URL); 'anyformer-gguf/<key>/'
|
|
13
|
-
// (GGUFs over 2 GB as shards, meta.json or done.json naming the repo; src/gguf-split.js)
|
|
13
|
+
// (GGUFs over 2 GB as shards, meta.json or done.json naming the repo; src/gguf-split.js);
|
|
14
|
+
// 'gguf/<name>' (diskformer-llama: llama.cpp's GGUFs, with <name>.part and .part.json while downloading;
|
|
15
|
+
// anyformer-llama.json maps each name to its repo).
|
|
14
16
|
|
|
15
17
|
import { DISKFORMER_MODELS } from './backends/diskformer.js';
|
|
16
18
|
import { SPLIT_ROOT } from './gguf-split.js';
|
|
19
|
+
import { readLlamaIndex, LLAMA_INDEX } from './backends/diskformer-llama.js';
|
|
17
20
|
|
|
18
21
|
const CACHE_BACKEND = { 'transformers-cache': 'transformers', 'gguf-v1': 'localmind', 'gguf-v1-headers': 'localmind', 'bonsai-pipeline-v1': 'localmind' };
|
|
19
22
|
const IDB_STORES = ['gguf-cache-v1', 'safetensors-cache-v1'];
|
|
@@ -86,7 +89,7 @@ async function inventory() {
|
|
|
86
89
|
if (ssd) for await (const [key, h] of ssd.entries()) {
|
|
87
90
|
if (h.kind !== 'directory') continue;
|
|
88
91
|
const src = (await readJson(h, 'manifest.json'))?.source;
|
|
89
|
-
const repo = src?.repo || repoOf(src?.url) || DISKFORMER_MODELS.find((m) => m.file
|
|
92
|
+
const repo = src?.repo || repoOf(src?.url) || DISKFORMER_MODELS.find((m) => m.file?.toLowerCase().startsWith(key))?.repo;
|
|
90
93
|
add(DISKFORMER_MODELS.some((m) => m.repo === repo) ? 'diskformer' : 'localmind', repo, await dirBytes(h), () => ssd.removeEntry(key, { recursive: true }));
|
|
91
94
|
}
|
|
92
95
|
const wcache = await opfsDir(['cache']);
|
|
@@ -103,6 +106,22 @@ async function inventory() {
|
|
|
103
106
|
const repo = (await readJson(h, 'meta.json'))?.repo || (await readJson(h, 'done.json'))?.repo;
|
|
104
107
|
add('wllama', repo, await dirBytes(h), () => split.removeEntry(key, { recursive: true }));
|
|
105
108
|
}
|
|
109
|
+
const llama = await opfsDir(['gguf']);
|
|
110
|
+
if (llama) {
|
|
111
|
+
const index = await readLlamaIndex();
|
|
112
|
+
for await (const [name, h] of llama.entries()) {
|
|
113
|
+
if (h.kind !== 'file' || name.endsWith('.part.json') || name.endsWith('.crswap')) continue;
|
|
114
|
+
const base = name.replace(/\.part$/, '');
|
|
115
|
+
add('diskformer-llama', index[base], (await h.getFile()).size, async () => {
|
|
116
|
+
for (const n of [base, `${base}.part`, `${base}.part.json`]) await llama.removeEntry(n).catch(() => {});
|
|
117
|
+
const rest = await readLlamaIndex();
|
|
118
|
+
delete rest[base];
|
|
119
|
+
const w = await (await (await navigator.storage.getDirectory()).getFileHandle(LLAMA_INDEX, { create: true })).createWritable();
|
|
120
|
+
await w.write(JSON.stringify(rest));
|
|
121
|
+
await w.close();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
106
125
|
return out;
|
|
107
126
|
}
|
|
108
127
|
|