anyformer 0.4.13 → 0.4.15
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 +34 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/backends/diskformer-onnx.js +40 -12
- package/src/backends/transformers.js +14 -7
- package/src/load.js +2 -2
- package/src/models.js +4 -3
- package/src/sources.js +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.15 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- A caller that stops reading a reply early (a `break` out of `for await`, as after a tool call) without aborting
|
|
7
|
+
now stops the reply on `diskformer-onnx` and `transformers`: the back end aborts the engine call (a linked
|
|
8
|
+
AbortController for diskformer-onnx, the stopping criteria's interrupt for transformers.js) and waits for it before
|
|
9
|
+
the next generate() starts. Before, the engine kept generating, and the next reply ran on the same onnxruntime
|
|
10
|
+
sessions beside it: "failed to call OrtRun() … Failed to download data from buffer" or a hang of over 10 minutes in
|
|
11
|
+
Chrome (reported by NakliOS, root-caused by LocalMind). `diskformer-llama` already did this.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- diskformer pin 1fe19f8 → 7ffbea5 (v0.6.13: LFM3-Omni serves one reply at a time; changes only
|
|
15
|
+
`engines/lfm3_omni.js`).
|
|
16
|
+
- Browser tests `omni` and `omni-speech` break out of a reply (text, and spoken at its first speech chunk) without
|
|
17
|
+
aborting, then require the next reply to equal the text reference within 10 s. Passed 2026-10-30 (Chrome, this Mac,
|
|
18
|
+
local files), with `transformers` and the LFM3 1.7B `chat` check.
|
|
19
|
+
|
|
20
|
+
## 0.4.14 — 2026-10-30
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- `diskformer-onnx`: LFM3-Omni speech out. `load(MODELS.chat.LFM3_OMNI_2B, { speech: true })` also fetches the depth
|
|
24
|
+
and codec decoders (515 MB, Cache Storage 'diskformer-models'); `generate(messages, { speech: true })` answers in
|
|
25
|
+
interleaved text and speech, and a chunk with new speech carries `audio` (24 kHz Float32 PCM since the last chunk)
|
|
26
|
+
and `sampleRate`. `seed`, `audioTemperature`, `audioRepetitionPenalty` and `audioRepetitionWindow` pass through.
|
|
27
|
+
Catalog flag `audioOut: true` on `MODELS.chat.LFM3_OMNI_2B`.
|
|
28
|
+
- Browser test `omni-speech` (Chrome, this Mac, 2026-10-30, local files): the reference speech clip in, a spoken reply
|
|
29
|
+
that names Mount Everest, 13.5 s of 24 kHz speech in 15 chunks over 9.3 s (first sound after 1.1 s), RMS 0.037
|
|
30
|
+
(the reference's 0.048); an abort from the first speech chunk ended the reply after 0.9 s of speech; a text reply
|
|
31
|
+
after it still equals the text reference. Code-level exactness is diskformer's own gate.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- diskformer pin 48f2c2a → 1fe19f8 (v0.6.12, LFM3-Omni speech out; changes only `engines/lfm3_omni.js`). The `omni`
|
|
35
|
+
test passes again on it (3/3 equal to the reference, streaming, abort, tool call).
|
|
36
|
+
|
|
3
37
|
## 0.4.13 — 2026-10-30
|
|
4
38
|
|
|
5
39
|
### Added
|
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ back end's answer: yes with an estimate (GPU bytes, disk bytes, tok/s when measu
|
|
|
55
55
|
| Back end | Runs | Models |
|
|
56
56
|
|---|---|---|
|
|
57
57
|
| `transformers` | transformers.js 4.3.2: text generation (Nemotron 3.5 Bolt E4B and LFM3 1.7B among them), 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
|
-
| `diskformer-onnx` | diskformer's hand engines on onnxruntime-web WebGPU, for ONNX models transformers.js has no class for | LFM3-Omni-2B: text, image and speech in
|
|
58
|
+
| `diskformer-onnx` | diskformer's hand engines on onnxruntime-web WebGPU, for ONNX models transformers.js has no class for | LFM3-Omni-2B: text, image and speech in; text or text and speech out |
|
|
59
59
|
| `kohra` | kohra, text-diffusion over onnxruntime-web | MDLM and BD3LM repos tagged `text-diffusion`; Nemotron-Labs-Diffusion-3B |
|
|
60
60
|
| `localmind` | LocalMind's hand-written WebGPU kernels (shipped in diskformer), the whole model on the GPU | any LFM2 / LFM2.5 GGUF; Gemma 4 E2B / E4B QAT-mobile; Ternary Bonsai 2 |
|
|
61
61
|
| `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 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anyformer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.15",
|
|
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",
|
|
@@ -11,7 +11,9 @@ import { SOURCES } from '../sources.js';
|
|
|
11
11
|
|
|
12
12
|
// Keyed by config.model_type. graphs: the ONNX files the engine reads (each with its _data file); json: the small files.
|
|
13
13
|
export const ONNX_ENGINES = {
|
|
14
|
-
// LiquidAI LFM3-Omni-2B: text, image and speech in (16 kHz Float32Array, at most 30 s a clip)
|
|
14
|
+
// LiquidAI LFM3-Omni-2B: text, image and speech in (16 kHz Float32Array, at most 30 s a clip); text out, or text and
|
|
15
|
+
// speech interleaved (24 kHz, from the depth and codec decoders, 515 MB more, fetched on the first spoken reply or by
|
|
16
|
+
// load({ speech: true })). transformers.js
|
|
15
17
|
// 4.3.2 has no lfm3_omni class; swap to transformers.js when it adds the model. diskformer's gate
|
|
16
18
|
// (test/browser/lfm3-omni.html, onnxruntime-web 1.30.0): prompt ids and 32 greedy tokens equal Liquid's runner on
|
|
17
19
|
// onnxruntime's CPU EP for text, image and speech, 3/3.
|
|
@@ -57,11 +59,12 @@ export const diskformerOnnxBackend = {
|
|
|
57
59
|
return { ok: true, estimate: { gpuBytes: bytes, diskBytes: bytes, tokPerSec: null } };
|
|
58
60
|
},
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
// speech: true also fetches the speech-out graphs now, so the first spoken reply does not wait for them.
|
|
63
|
+
async load(model, { speech = false, onProgress, signal }) {
|
|
61
64
|
const e = ONNX_ENGINES[model.architecture];
|
|
62
65
|
const mod = await import(`${diskformerOnnxBackend.source}/${e.engine}`);
|
|
63
66
|
const baseUrl = diskformerOnnxBackend.url ? diskformerOnnxBackend.url(model) : `https://huggingface.co/${model.repo}/resolve/${model.revision || 'main'}/`;
|
|
64
|
-
const engine = await mod[e.Engine].load({ baseUrl, onProgress: onProgress && ((p) => onProgress({ backend: 'diskformer-onnx', ...p })) });
|
|
67
|
+
const engine = await mod[e.Engine].load({ baseUrl, ...(speech && { speech: true }), onProgress: onProgress && ((p) => onProgress({ backend: 'diskformer-onnx', ...p })) });
|
|
65
68
|
if (signal?.aborted) { await engine.dispose(); signal.throwIfAborted(); }
|
|
66
69
|
return {
|
|
67
70
|
task: 'text-generation',
|
|
@@ -73,22 +76,47 @@ export const diskformerOnnxBackend = {
|
|
|
73
76
|
// or { data, width, height }) and { type: 'audio', audio } (16 kHz mono Float32Array). thinking: false (the
|
|
74
77
|
// default, as on every back end here) answers directly. tools: the chat template's tool list; tool calls stay
|
|
75
78
|
// raw text in the reply (LFM3 writes Qwen3-Coder XML), for the caller to parse. An abort ends the reply quietly.
|
|
76
|
-
|
|
79
|
+
// speech: true answers in interleaved text and speech (the engine adds its speech system turn when the messages
|
|
80
|
+
// have none, and thinks never): a chunk that carries new speech has `audio`, the 24 kHz Float32 PCM since the last
|
|
81
|
+
// chunk, and `sampleRate`; maxNewTokens then counts text tokens and speech frames (80 ms each) together. Speech is
|
|
82
|
+
// sampled (generation_config.json: temperature 0.4, repetition penalty 1.2 over 64 frames); audioTemperature,
|
|
83
|
+
// audioRepetitionPenalty, audioRepetitionWindow and seed override it.
|
|
84
|
+
async *generate(messages, { maxNewTokens = 512, signal, thinking = false, tools, repetitionPenalty, speech = false, seed,
|
|
85
|
+
audioTemperature, audioRepetitionPenalty, audioRepetitionWindow } = {}) {
|
|
77
86
|
const queue = [];
|
|
78
87
|
let wake = () => {}, done = false, failed = null, text = '', tokens = 0;
|
|
79
88
|
const msgs = await media(messages);
|
|
80
|
-
engine.
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
const sampleRate = engine.speech?.sampling_rate ?? 24000;
|
|
90
|
+
// The engine reads this signal, linked to the caller's, so a caller that stops reading can end the reply too.
|
|
91
|
+
const stop = new AbortController();
|
|
92
|
+
const abort = () => stop.abort();
|
|
93
|
+
if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
|
|
94
|
+
const job = engine.generate(msgs, {
|
|
95
|
+
max_new_tokens: maxNewTokens, do_sample: false, enable_thinking: thinking === true, signal: stop.signal,
|
|
96
|
+
...(tools && { tools }), ...(repetitionPenalty && { repetition_penalty: repetitionPenalty }), ...(seed !== undefined && { seed }),
|
|
97
|
+
...(speech && {
|
|
98
|
+
output_modalities: ['text', 'audio'],
|
|
99
|
+
onAudio: (pcm) => { queue.push({ text, tokens, audio: pcm, sampleRate }); wake(); },
|
|
100
|
+
...(audioTemperature !== undefined && { audio_temperature: audioTemperature }),
|
|
101
|
+
...(audioRepetitionPenalty !== undefined && { audio_repetition_penalty: audioRepetitionPenalty }),
|
|
102
|
+
...(audioRepetitionWindow !== undefined && { audio_repetition_window: audioRepetitionWindow }),
|
|
103
|
+
}),
|
|
83
104
|
onToken: () => { tokens++; },
|
|
84
105
|
onText: (delta) => { text += delta; queue.push({ text, tokens }); wake(); },
|
|
85
106
|
}).catch((err) => { failed = err; }).finally(() => { done = true; wake(); });
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
try {
|
|
108
|
+
while (true) {
|
|
109
|
+
if (queue.length) { yield queue.shift(); continue; }
|
|
110
|
+
if (done) break;
|
|
111
|
+
await new Promise((r) => { wake = r; });
|
|
112
|
+
}
|
|
113
|
+
if (failed) throw failed;
|
|
114
|
+
} finally {
|
|
115
|
+
signal?.removeEventListener('abort', abort);
|
|
116
|
+
// The caller stopped reading early (a break after a tool call): stop the reply, so the next call on this
|
|
117
|
+
// engine's sessions does not run beside it or queue behind it.
|
|
118
|
+
if (!done) { stop.abort(); await job; }
|
|
90
119
|
}
|
|
91
|
-
if (failed) throw failed;
|
|
92
120
|
},
|
|
93
121
|
dispose: () => engine.dispose(),
|
|
94
122
|
};
|
|
@@ -177,14 +177,21 @@ async function* stream(lib, tokenizer, signal, run) {
|
|
|
177
177
|
token_callback_function: () => { tokens++; },
|
|
178
178
|
});
|
|
179
179
|
const stop = new lib.InterruptableStoppingCriteria();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
180
|
+
const interrupt = () => stop.interrupt();
|
|
181
|
+
signal?.addEventListener('abort', interrupt, { once: true });
|
|
182
|
+
const job = run(streamer, stop).catch((e) => { failed = e; }).finally(() => { done = true; wake(); });
|
|
183
|
+
try {
|
|
184
|
+
while (true) {
|
|
185
|
+
if (queue.length) { yield queue.shift(); continue; }
|
|
186
|
+
if (done) break;
|
|
187
|
+
await new Promise((r) => { wake = r; });
|
|
188
|
+
}
|
|
189
|
+
if (failed) throw failed;
|
|
190
|
+
} finally {
|
|
191
|
+
signal?.removeEventListener('abort', interrupt);
|
|
192
|
+
// The caller stopped reading early: stop the reply so the next call on this model does not run beside it.
|
|
193
|
+
if (!done) { stop.interrupt(); await job; }
|
|
186
194
|
}
|
|
187
|
-
if (failed) throw failed;
|
|
188
195
|
}
|
|
189
196
|
|
|
190
197
|
// A text-only model in the multimodal layout: the causal-LM class over embed_tokens + decoder_model_merged.
|
package/src/load.js
CHANGED
|
@@ -43,14 +43,14 @@ export async function plan(repoOrEntry, opts = {}) {
|
|
|
43
43
|
|
|
44
44
|
export async function load(repoOrEntry, options = {}) {
|
|
45
45
|
const [repo, opts] = entry(repoOrEntry, options);
|
|
46
|
-
const { backends = BACKENDS, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, onProgress, signal } = opts;
|
|
46
|
+
const { backends = BACKENDS, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, speech, onProgress, signal } = opts;
|
|
47
47
|
let p;
|
|
48
48
|
try { p = await plan(repo, opts); } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${repo}: `); }
|
|
49
49
|
if (!p.chosen) throw noBackend(p);
|
|
50
50
|
const backend = backends.find((b) => b.id === p.chosen);
|
|
51
51
|
let session;
|
|
52
52
|
try {
|
|
53
|
-
session = await backend.load(p.model, { budget: p.budget, task: p.task, file: p.file, caps: p.caps, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, onProgress, signal });
|
|
53
|
+
session = await backend.load(p.model, { budget: p.budget, task: p.task, file: p.file, caps: p.caps, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, speech, onProgress, signal });
|
|
54
54
|
} catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${backend.id}: `); }
|
|
55
55
|
// Added onto serialize()'s object, not spread: a spread would read its getters once and freeze them.
|
|
56
56
|
return Object.assign(serialize(session), { backend: backend.id, task: session.task || p.task || p.model.task || null, ...(session.device && { device: session.device }), plan: p });
|
package/src/models.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// repo id; it carries the task and, where the back end needs one, the budget. DEFAULT is the smallest download that
|
|
3
3
|
// passed. `test` names the page under test/browser/ that proves the entry; test/models.test.js checks it exists.
|
|
4
4
|
// `diffusion: true` marks a text-diffusion model (the reply denoises in blocks); `experimental: true` marks an entry its
|
|
5
|
-
// app shows as opt-in until that app's own checks pass
|
|
5
|
+
// app shows as opt-in until that app's own checks pass; `audioOut: true` marks a model that can also answer in speech
|
|
6
|
+
// (generate({ speech: true })).
|
|
6
7
|
|
|
7
8
|
const M = (repo, backend, task, test, extra = {}) => ({ repo, backend, task, test, ...extra });
|
|
8
9
|
|
|
@@ -23,7 +24,7 @@ const GEMMA4_26B_A4B = M('google/gemma-4-26B-A4B-it-qat-q4_0-gguf', 'diskformer'
|
|
|
23
24
|
const LFM3_1_7B = M('LiquidAI/LFM3-1.7B-ONNX', 'transformers', 'text-generation', 'chat', { downloadBytes: 1.63e9 });
|
|
24
25
|
// transformers.js 4.3.2 has no lfm3_omni class, so diskformer's hand engine runs it; swap the entry to transformers.js
|
|
25
26
|
// when it adds the model.
|
|
26
|
-
const LFM3_OMNI_2B = M('LiquidAI/LFM3-Omni-2B-ONNX', 'diskformer-onnx', 'any-to-any', 'omni', { downloadBytes: 2.14e9 });
|
|
27
|
+
const LFM3_OMNI_2B = M('LiquidAI/LFM3-Omni-2B-ONNX', 'diskformer-onnx', 'any-to-any', 'omni', { downloadBytes: 2.14e9, audioOut: true });
|
|
27
28
|
const NEMOTRON_3_5_BOLT_E4B = M('danyalmalik/NVIDIA-Nemotron-3.5-Bolt-E4B-ONNX-WebGPU', 'transformers', 'text-generation', 'transformers', { budget: 5e9, downloadBytes: 4.94e9 });
|
|
28
29
|
// A tech demo, not a gated model: 231 GB on disk (4 IQ1_M parts plus the 110.6 GB Engram side file), about 0.4 tok/s.
|
|
29
30
|
// diskformer checked one hand-written prompt through complete(); generate() renders the GGUF's chat template, which no
|
|
@@ -60,7 +61,7 @@ export const MODELS = {
|
|
|
60
61
|
NEMOTRON_3_5_BOLT_E4B,
|
|
61
62
|
/** LiquidAI's LFM3 1.7B (dense), LiquidAI's own ONNX export; thinking off unless a request asks for it. */
|
|
62
63
|
LFM3_1_7B,
|
|
63
|
-
/** LiquidAI's LFM3-Omni-2B: text, image and speech in (16 kHz, at most 30 s a clip)
|
|
64
|
+
/** LiquidAI's LFM3-Omni-2B: text, image and speech in (16 kHz, at most 30 s a clip); text or text and speech out (diskformer's engine). */
|
|
64
65
|
LFM3_OMNI_2B,
|
|
65
66
|
/** DeepSeek V4.1 Flash (763B MoE), a tech demo on diskformer's llama.cpp path: 231 GB on disk, very slow. */
|
|
66
67
|
DEEPSEEK_V4_1_FLASH,
|
package/src/sources.js
CHANGED
|
@@ -9,6 +9,9 @@ export const PINS = {
|
|
|
9
9
|
// v0.3.1: seeded block-KV graphs (Nemotron-Labs-Diffusion-3B), chatFormat, split external data; download progress
|
|
10
10
|
// (progress_callback) and the graph files cached in Cache Storage 'kohra-models'.
|
|
11
11
|
kohra: 'bf95d44c3e3cfc001201baa03fa121c2bf9c7d73',
|
|
12
|
+
// v0.6.13 (7ffbea5): LFM3-Omni serves one reply at a time; overlapping runs broke onnxruntime-web WebGPU.
|
|
13
|
+
// v0.6.12 (1fe19f8): LFM3-Omni speech out (interleaved text and 24 kHz speech; depth and codec decoders). Changes
|
|
14
|
+
// only engines/lfm3_omni.js; its text, image and speech-in gate (3/3) is unchanged.
|
|
12
15
|
// 48f2c2a (main after v0.6.9): engines/lfm3_omni.js, LFM3-Omni-2B on onnxruntime-web 1.30.0 (text, image and speech
|
|
13
16
|
// in; text out; files in Cache Storage 'diskformer-models'). Adds files only: no other engine or ggml/ changed.
|
|
14
17
|
// v0.6.9: llama.load({ extra }) mounts side files beside the GGUF (DeepSeek V4.1 Flash's Engram tables).
|
|
@@ -16,7 +19,7 @@ export const PINS = {
|
|
|
16
19
|
// Gemma 4 E2B/E4B and Ternary Bonsai 2) under engines/, and the llama.cpp path under ggml/, which loads split GGUFs
|
|
17
20
|
// and drafters, and reads past 2 GiB (Nemotron 3.5 Bolt E4B needs v0.6.8 or later). The hand engines' ingest
|
|
18
21
|
// streams the GGUF header with progress and resumes a dropped range.
|
|
19
|
-
diskformer: '
|
|
22
|
+
diskformer: '7ffbea54a5f09494e1f6c970cce7c4c7b1b8460d',
|
|
20
23
|
};
|
|
21
24
|
|
|
22
25
|
const gh = (repo, sha, path) => `https://cdn.jsdelivr.net/gh/NakliTechie/${repo}@${sha}/${path}`;
|