anyformer 0.4.19 → 0.4.21
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 +46 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/backends/diskformer-onnx.js +2 -0
- package/src/backends/diskformer.js +32 -3
- package/src/load.js +24 -7
- package/src/models.js +11 -9
- package/src/sources.js +4 -1
- package/src/tool-calls.js +186 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.21 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- diskformer pin cefebc7 → 7962912 (v0.6.20: run-time LoRA in every native MoE engine through `engines/lora.js`).
|
|
7
|
+
The `diskformer` back end now takes `adapter` on Gemma 4 26B and Qwen3.6 35B too. diskformer's gates (plain /
|
|
8
|
+
adapter / plain again, against llama-server --lora on the same file): Gemma 4 26B 9/9, 8/9, 9/9; Qwen3.6 35B 6/9,
|
|
9
|
+
9/9, 6/9. Adapters on routed experts are refused with an error. MiniCPM-V, Clover, Flint and Ling take no adapter,
|
|
10
|
+
so an `adapter` drops `diskformer` from their plans.
|
|
11
|
+
|
|
12
|
+
### Not yet verified
|
|
13
|
+
- No browser run of an adapter on Gemma 4 or Qwen3.6 through anyformer: no adapter GGUF for either is in the catalog.
|
|
14
|
+
|
|
15
|
+
## 0.4.20 — 2026-10-31
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Tool calls on every back end: `generate(messages, { tools })` takes OpenAI-style tools. A back end whose chat template
|
|
19
|
+
renders them (`diskformer-onnx`) gets them as they are; for the others anyformer writes the tool list, assistant
|
|
20
|
+
`tool_calls` turns and `tool` results into the conversation in the model family's format (`src/tool-calls.js`: LFM2's
|
|
21
|
+
`<|tool_list_start|>` / `<|tool_call_start|>[name(k=v)]<|tool_call_end|>`; Qwen's hermes `<tools>` /
|
|
22
|
+
`<tool_call>{json}</tool_call>` for the rest). A chunk whose text holds complete calls carries them as `toolCalls`
|
|
23
|
+
(OpenAI `tool_calls`, ids `call_<n>`) and its text without them. Qwen3-Coder XML calls (LFM3) parse too, and for LFM2
|
|
24
|
+
a bare call list ending the reply, since LocalMind's LFM2 engine decodes without special tokens. An assistant turn
|
|
25
|
+
with only `tool_calls` and no content is valid input.
|
|
26
|
+
- Run-time LoRA on diskformer's LFM2.5 engine (v0.6.19): the `diskformer` back end applies `adapter` with
|
|
27
|
+
`engine.loadAdapter` / `setAdapter`; `session.setAdapter(false / true)` switches it off and on with no reload.
|
|
28
|
+
`backend.adapters` may be a function of the model.
|
|
29
|
+
- Browser test `tools`: one get_weather call and its result, then a text answer with the temperature.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- `MODELS.chat.LFM2_5_24B_PI` moves to the `diskformer` back end (the base QAD-Q4_0 GGUF plus the 37 MB adapter at run
|
|
33
|
+
time; diskformer's gate 9/9 against llama-server --lora). diskformer-llama still applies it where the hand engine
|
|
34
|
+
cannot run.
|
|
35
|
+
- With thinking off, a diskformer engine that always reasons with the block opened by its prompt (LFM2.5-24B:
|
|
36
|
+
`alwaysThinks` and `thinkInPrompt`) returns only the answer in `text`, and the reasoning in `reasoning`.
|
|
37
|
+
- diskformer pin 317d2a0 → cefebc7 (v0.6.19: run-time LoRA in `lfm2_moe_ssd.js`).
|
|
38
|
+
|
|
39
|
+
### Verified (after the npm publish, on the published code)
|
|
40
|
+
- `tools` on LFM2.5 230M (`localmind`, local file): `get_weather` {"city":"Paris"} parsed from the bare call list;
|
|
41
|
+
after the tool result, an answer with 18°C and clear sky.
|
|
42
|
+
- `tools` on `LFM2_5_24B_PI` (`diskformer`, run-time adapter, base GGUF and adapter from local files, 13:31 IST): the
|
|
43
|
+
first turn's text is empty (reasoning in `reasoning`) with `get_weather` {"city":"Paris"} as `toolCalls`; then
|
|
44
|
+
"The current weather in Paris is **clear skies** with a temperature of **18°C**."
|
|
45
|
+
- `chat?entry=chat.LFM2_5_24B_PI&turns=4` on the same engine (by 13:41 IST): PASS, 4/4 turns on topic with no
|
|
46
|
+
reasoning in `text`; load 50 s from OPFS. The page's tok/s (about 2.5) counts only answer tokens after the hidden
|
|
47
|
+
reasoning, so it is not a decode speed.
|
|
48
|
+
|
|
3
49
|
## 0.4.19 — 2026-10-31
|
|
4
50
|
|
|
5
51
|
### Changed
|
package/README.md
CHANGED
|
@@ -78,6 +78,7 @@ and `threw`. Every other failure is an `AnyformerError` too, with a code from `E
|
|
|
78
78
|
```js
|
|
79
79
|
plan(repo, { budget, task }) // what would run it (adapter: a LoRA GGUF; only diskformer-llama applies one), and why the others would not; loads nothing
|
|
80
80
|
load(repo, { budget, task, file, adapter, maxCtx, poolBytes, localFile, fetch, ple, speculative, onProgress, signal }) // → { backend, task, plan, run(input), generate?, think?, prime?, reset?, speculative?, dispose() }
|
|
81
|
+
generate(messages, { tools }) // OpenAI-style tools in; chunks carry parsed toolCalls (LFM2, Qwen/hermes, Qwen3-Coder XML)
|
|
81
82
|
pipeline(task, repo, { budget }) // transformers.js's call shape: gen(messages) → [{ generated_text }]
|
|
82
83
|
probe() // WebGPU, shader-f16, subgroups, limits, OPFS quota, memory64
|
|
83
84
|
describe(repo) // Hub repo → formats, architecture, task, tags, files
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anyformer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.21",
|
|
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",
|
|
@@ -81,6 +81,8 @@ export const diskformerOnnxBackend = {
|
|
|
81
81
|
// chunk, and `sampleRate`; maxNewTokens then counts text tokens and speech frames (80 ms each) together. Speech is
|
|
82
82
|
// sampled (generation_config.json: temperature 0.4, repetition penalty 1.2 over 64 frames); audioTemperature,
|
|
83
83
|
// audioRepetitionPenalty, audioRepetitionWindow and seed override it.
|
|
84
|
+
// The chat template renders the tool list itself; anyformer only parses the calls.
|
|
85
|
+
templateTools: true,
|
|
84
86
|
async *generate(messages, { maxNewTokens = 512, signal, thinking = false, tools, repetitionPenalty, speech = false, seed,
|
|
85
87
|
audioTemperature, audioRepetitionPenalty, audioRepetitionWindow } = {}) {
|
|
86
88
|
const queue = [];
|
|
@@ -11,11 +11,15 @@ export const DISKFORMER_MODELS = [
|
|
|
11
11
|
{
|
|
12
12
|
repo: 'google/gemma-4-26B-A4B-it-qat-q4_0-gguf', file: 'gemma-4-26B_q4_0-it.gguf', size: 14439363584,
|
|
13
13
|
engine: 'gemma4_moe_ssd.js', Engine: 'Gemma4MoeSsd', minBudget: 2.5e9,
|
|
14
|
+
// LoRA at run time (v0.6.20+, gated against llama-server --lora on the same adapter file).
|
|
15
|
+
adapters: true,
|
|
14
16
|
speeds: [[2.5e9, 12.4], [4.3e9, 14.2], [7e9, 17.9]],
|
|
15
17
|
},
|
|
16
18
|
{
|
|
17
19
|
repo: 'unsloth/Qwen3.6-35B-A3B-GGUF', file: 'Qwen3.6-35B-A3B-Q8_0.gguf', size: 36903140320,
|
|
18
20
|
engine: 'qwen35_moe_ssd.js', Engine: 'Qwen35MoeSsd', minBudget: 4e9,
|
|
21
|
+
// LoRA at run time (v0.6.20+, gated against llama-server --lora on the same adapter file).
|
|
22
|
+
adapters: true,
|
|
19
23
|
speeds: [[4e9, 9.2]],
|
|
20
24
|
},
|
|
21
25
|
// Qwen3.6's language model with a vision encoder (mmproj, ~1.1 GB, loaded on the first image by ensureVision).
|
|
@@ -51,6 +55,8 @@ export const DISKFORMER_MODELS = [
|
|
|
51
55
|
{
|
|
52
56
|
repo: 'LiquidAI/LFM2.5-24B-A2B-GGUF', file: 'LFM2.5-24B-A2B-QAD-Q4_0.gguf', size: 13578190880,
|
|
53
57
|
engine: 'lfm2_moe_ssd.js', Engine: 'Lfm2MoeSsd', minBudget: 4e9,
|
|
58
|
+
// LoRA at run time (v0.6.19+: engine.loadAdapter / setAdapter; adapters on the routed experts are refused).
|
|
59
|
+
adapters: true,
|
|
54
60
|
speeds: [],
|
|
55
61
|
},
|
|
56
62
|
// Two parts, ingested as one file by the engine's splitFetch (engines/ling_moe_ssd.js LING_FLASH, rev 29edce5).
|
|
@@ -91,6 +97,10 @@ export const diskformerBackend = {
|
|
|
91
97
|
source: SOURCES.diskformer,
|
|
92
98
|
// Where the engine downloads its GGUF from; null is Hugging Face. A test points it at a local server.
|
|
93
99
|
url: null,
|
|
100
|
+
// Where an adapter GGUF downloads from.
|
|
101
|
+
adapterUrl: (a) => `https://huggingface.co/${a.repo}/resolve/${a.revision || 'main'}/${a.file.split('/').map(encodeURIComponent).join('/')}`,
|
|
102
|
+
// The engines that apply a LoRA at run time.
|
|
103
|
+
adapters: (model) => !!match(model)?.adapters,
|
|
94
104
|
|
|
95
105
|
canRun(model, caps, { budget, task }) {
|
|
96
106
|
if (!model.formats.includes('gguf')) return { ok: false, code: 'format', reason: 'no GGUF in the repo' };
|
|
@@ -111,7 +121,9 @@ export const diskformerBackend = {
|
|
|
111
121
|
// its pool from gpuBudgetBytes when given one, so it gets one or the other).
|
|
112
122
|
// localFile: the GGUF as a File the user picked; the engine ingests it instead of downloading, and refuses a file
|
|
113
123
|
// whose size differs from the one it is pinned to.
|
|
114
|
-
|
|
124
|
+
// adapter: { repo, file, revision, scale } loads onto the GPU beside the model and is on for every reply;
|
|
125
|
+
// session.setAdapter(false / true) switches it off and on again with no reload.
|
|
126
|
+
async load(model, { budget, maxCtx = 1024, poolBytes, localFile, adapter = null, onProgress, signal }) {
|
|
115
127
|
const m = match(model);
|
|
116
128
|
const mod = await import(`${diskformerBackend.source}/${m.engine}`);
|
|
117
129
|
let engine;
|
|
@@ -124,6 +136,14 @@ export const diskformerBackend = {
|
|
|
124
136
|
onProgress: onProgress && ((e) => onProgress({ backend: 'diskformer', ...e })),
|
|
125
137
|
});
|
|
126
138
|
} catch (e) { throw budgetError(e) || e; }
|
|
139
|
+
let lora = null;
|
|
140
|
+
if (adapter) {
|
|
141
|
+
try {
|
|
142
|
+
onProgress?.({ backend: 'diskformer', status: 'adapter', file: adapter.file });
|
|
143
|
+
lora = await engine.loadAdapter(diskformerBackend.adapterUrl(adapter), { scale: adapter.scale ?? 1 });
|
|
144
|
+
await engine.setAdapter(lora);
|
|
145
|
+
} catch (e) { await engine.dispose?.(); throw e; }
|
|
146
|
+
}
|
|
127
147
|
// Where the model's reasoning starts and ends, as token ids, so a caller can split it out without parsing text.
|
|
128
148
|
const open = engine.thinkOpenTokenId ?? null;
|
|
129
149
|
return {
|
|
@@ -136,14 +156,23 @@ export const diskformerBackend = {
|
|
|
136
156
|
// thinking: false (the default, as on every back end here) answers directly; true lets a model that reasons
|
|
137
157
|
// (Gemma 4, Qwen3.6, Ling) think first. Image blocks ({ type: 'image', rgb, width, height }) pass through to
|
|
138
158
|
// an engine that reads them.
|
|
159
|
+
// A model that always reasons with the block opened by its prompt (LFM2.5-24B: engine.alwaysThinks and
|
|
160
|
+
// thinkInPrompt) cannot answer directly, so with thinking off its reasoning moves to `reasoning` and `text` holds
|
|
161
|
+
// only what follows </think> (empty until then).
|
|
139
162
|
async *generate(messages, { maxNewTokens = 512, signal, thinking = false } = {}) {
|
|
140
|
-
|
|
141
|
-
|
|
163
|
+
const split = thinking !== true && engine.alwaysThinks === true && engine.thinkInPrompt === true;
|
|
164
|
+
for await (const r of engine.generate(messages, { maxNewTokens, signal, enableThinking: thinking === true })) {
|
|
165
|
+
if (!split) { yield { text: r.text, token: r.token, tokens: r.tokens }; continue; }
|
|
166
|
+
const at = r.text.indexOf('</think>');
|
|
167
|
+
yield at < 0 ? { text: '', reasoning: r.text, token: r.token, tokens: r.tokens }
|
|
168
|
+
: { text: r.text.slice(at + 8).replace(/^\s+/, ''), reasoning: r.text.slice(0, at), token: r.token, tokens: r.tokens };
|
|
169
|
+
}
|
|
142
170
|
},
|
|
143
171
|
// Checkpoints a system prompt after load, so the first question does not wait for it (Qwen3.6, MiniCPM-V).
|
|
144
172
|
...(typeof engine.prime === 'function' && { prime: (system) => engine.prime(system) }),
|
|
145
173
|
// Loads the vision tower, for an engine that reads images (MiniCPM-V).
|
|
146
174
|
...(typeof engine.ensureVision === 'function' && { ensureVision: (o) => engine.ensureVision(o) }),
|
|
175
|
+
...(lora && { setAdapter: (on) => engine.setAdapter(on ? lora : null) }),
|
|
147
176
|
dispose: () => engine.dispose(),
|
|
148
177
|
};
|
|
149
178
|
},
|
package/src/load.js
CHANGED
|
@@ -9,6 +9,7 @@ import { localmindBackend } from './backends/localmind.js';
|
|
|
9
9
|
import { wllamaBackend } from './backends/wllama.js';
|
|
10
10
|
import { diskformerOnnxBackend } from './backends/diskformer-onnx.js';
|
|
11
11
|
import { AnyformerError, wrap } from './errors.js';
|
|
12
|
+
import { toolFormat, withTools, parseToolCalls } from './tool-calls.js';
|
|
12
13
|
|
|
13
14
|
// Back ends land here as each one passes its browser test (test/browser/). The list order breaks ties: a
|
|
14
15
|
// diskformer hand engine carries a measured speed and outranks diskformer-llama for the same model; diskformer-llama
|
|
@@ -30,8 +31,9 @@ function noBackend(plan) {
|
|
|
30
31
|
// task: a Hugging Face pipeline task ('text-generation', 'feature-extraction', …); null runs the repo's own.
|
|
31
32
|
// file: a weights file in the repo to load (a GGUF name); null lets each back end pick.
|
|
32
33
|
// repo: a Hub id, or an entry from MODELS (its task, budget and adapter apply unless opts set them).
|
|
33
|
-
// adapter: a LoRA GGUF applied over the model, { repo, file, revision, size, scale }; only a back end
|
|
34
|
-
// `adapters
|
|
34
|
+
// adapter: a LoRA GGUF applied over the model, { repo, file, revision, size, scale }; only a back end whose
|
|
35
|
+
// `adapters` is true, or a function that says yes for the model, can apply one (diskformer's LFM2.5 engine at run time,
|
|
36
|
+
// diskformer-llama), so the others drop out of the plan.
|
|
35
37
|
const entry = (repo, opts) => (typeof repo === 'object' && repo?.repo
|
|
36
38
|
? [repo.repo, { ...(repo.task && { task: repo.task }), ...(repo.budget && { budget: repo.budget }), ...(repo.adapter && { adapter: repo.adapter }), ...opts }]
|
|
37
39
|
: [repo, opts]);
|
|
@@ -40,7 +42,7 @@ export async function plan(repoOrEntry, opts = {}) {
|
|
|
40
42
|
let [repo, { budget = DEFAULT_BUDGET, task = null, file = null, backends = BACKENDS, caps, model, fetch, adapter = null }] = entry(repoOrEntry, opts);
|
|
41
43
|
caps ??= await probe();
|
|
42
44
|
model ??= await describe(repo, fetch ? { fetch } : {});
|
|
43
|
-
if (adapter) backends = backends.filter((b) => b.adapters);
|
|
45
|
+
if (adapter) backends = backends.filter((b) => (typeof b.adapters === 'function' ? b.adapters(model) : b.adapters));
|
|
44
46
|
return { model, caps, budget, task, file, ...(adapter && { adapter }), ...(await resolve(model, caps, backends, { budget, task, file })) };
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -56,17 +58,22 @@ export async function load(repoOrEntry, options = {}) {
|
|
|
56
58
|
session = await backend.load(p.model, { budget: p.budget, task: p.task, file: p.file, adapter: p.adapter, caps: p.caps, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, speech, onProgress, signal });
|
|
57
59
|
} catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${backend.id}: `); }
|
|
58
60
|
// Added onto serialize()'s object, not spread: a spread would read its getters once and freeze them.
|
|
59
|
-
return Object.assign(serialize(session), { backend: backend.id, task: session.task || p.task || p.model.task || null, ...(session.device && { device: session.device }), plan: p });
|
|
61
|
+
return Object.assign(serialize(session, p.model.architecture), { backend: backend.id, task: session.task || p.task || p.model.task || null, ...(session.device && { device: session.device }), plan: p });
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
// content: a string, or an array of { type, … } blocks (text, image, audio) for multimodal models.
|
|
63
65
|
const validContent = (c) => typeof c === 'string' || (Array.isArray(c) && c.every((b) => typeof b?.type === 'string'));
|
|
64
|
-
|
|
66
|
+
// An assistant turn that only calls tools may have no content.
|
|
67
|
+
const validMessages = (messages) => Array.isArray(messages) && messages.every((m) => typeof m?.role === 'string' &&
|
|
68
|
+
(validContent(m?.content) || (m.role === 'assistant' && Array.isArray(m.tool_calls) && (m.content == null || m.content === ''))));
|
|
65
69
|
|
|
66
70
|
// One call at a time per session: two overlapping generates would share one KV cache. A call waits for the previous
|
|
67
71
|
// one to finish (or for its caller to stop iterating). dispose() waits for the running call, then frees the GPU;
|
|
68
72
|
// calls after it throw DISPOSED.
|
|
69
|
-
|
|
73
|
+
// tools: OpenAI-style tool definitions. A back end whose chat template takes them (templateTools) gets them as they are;
|
|
74
|
+
// for the others the conversation carries them as text in the model family's format (src/tool-calls.js). Either way a
|
|
75
|
+
// chunk whose text holds complete tool calls carries them as `toolCalls` (OpenAI tool_calls) and its text without them.
|
|
76
|
+
function serialize(session, architecture) {
|
|
70
77
|
let tail = Promise.resolve(), disposed = false;
|
|
71
78
|
const gone = () => new AnyformerError('DISPOSED', 'this session was disposed; load the model again');
|
|
72
79
|
const turn = async () => {
|
|
@@ -83,7 +90,15 @@ function serialize(session) {
|
|
|
83
90
|
const release = await turn();
|
|
84
91
|
try {
|
|
85
92
|
if (disposed) throw gone();
|
|
86
|
-
|
|
93
|
+
const tools = o.tools?.length ? o.tools : null;
|
|
94
|
+
const traffic = messages.some((m) => m.role === 'tool' || m.tool_calls?.length);
|
|
95
|
+
const asText = (tools || traffic) && !session.templateTools;
|
|
96
|
+
const msgs = asText ? withTools(messages, tools, toolFormat(architecture)) : messages;
|
|
97
|
+
const { tools: _, ...rest } = o;
|
|
98
|
+
for await (const chunk of session.generate(msgs, asText ? rest : o)) {
|
|
99
|
+
const found = tools && typeof chunk.text === 'string' ? parseToolCalls(chunk.text, toolFormat(architecture)) : null;
|
|
100
|
+
yield found?.toolCalls.length ? { ...chunk, text: found.content, toolCalls: found.toolCalls } : chunk;
|
|
101
|
+
}
|
|
87
102
|
} catch (e) {
|
|
88
103
|
throw wrap(e, 'EXECUTION_FAILED', o.signal);
|
|
89
104
|
} finally {
|
|
@@ -110,6 +125,8 @@ function serialize(session) {
|
|
|
110
125
|
...(session.prime && { prime: queued(session.prime) }),
|
|
111
126
|
...(session.ensureVision && { ensureVision: queued(session.ensureVision) }),
|
|
112
127
|
...(session.reset && { reset: queued(session.reset) }),
|
|
128
|
+
// Switches a loaded adapter on (true) or off (false) for the next reply, with no reload (diskformer's LFM2.5 engine).
|
|
129
|
+
...(session.setAdapter && { setAdapter: queued(session.setAdapter) }),
|
|
113
130
|
// The one call every task has. For text generation it is the last chunk of generate().
|
|
114
131
|
async run(input, o = {}) {
|
|
115
132
|
if (generate) { let last; for await (const c of generate(input, o)) last = c; return last; }
|
package/src/models.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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, so generate() chunks arrive a block at a time); `experimental: true` marks an entry its
|
|
5
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 })); `adapter` names a LoRA GGUF applied over the model (diskformer-llama
|
|
6
|
+
// (generate({ speech: true })); `adapter` names a LoRA GGUF applied over the model (diskformer's LFM2.5 engine, diskformer-llama).
|
|
7
7
|
|
|
8
8
|
const M = (repo, backend, task, test, extra = {}) => ({ repo, backend, task, test, ...extra });
|
|
9
9
|
|
|
@@ -32,13 +32,15 @@ const NEMOTRON_3_5_BOLT_E4B = M('danyalmalik/NVIDIA-Nemotron-3.5-Bolt-E4B-ONNX-W
|
|
|
32
32
|
// diskformer checked one hand-written prompt through complete(); generate() renders the GGUF's chat template, which no
|
|
33
33
|
// browser run has checked.
|
|
34
34
|
const DEEPSEEK_V4_1_FLASH = M('ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF', 'diskformer-llama', 'text-generation', 'llama-gate', { budget: 8e9, downloadBytes: 231316411072, experimental: true });
|
|
35
|
-
// LFM2.5-24B-A2B with harness-hub's Pi LoRA (rank 32, alpha 64; asynchronous GRPO inside the Pi coding agent),
|
|
36
|
-
// diskformer's
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
35
|
+
// LFM2.5-24B-A2B with harness-hub's Pi LoRA (rank 32, alpha 64; asynchronous GRPO inside the Pi coding agent), applied
|
|
36
|
+
// at run time by diskformer's LFM2.5 engine (v0.6.19: one base download, the 37 MB adapter beside it on the GPU,
|
|
37
|
+
// session.setAdapter(false) for the plain model with no reload). The adapter touches the conv, attention and two dense
|
|
38
|
+
// FFN layers only. diskformer's gate: 9/9 replies identical to llama-server --lora (plain 8/9 against llama.cpp, the
|
|
39
|
+
// engine's baseline). diskformer-llama applies it too (8/9) when the hand engine cannot run. Authors' numbers on 100
|
|
40
|
+
// data-analysis tasks: 39% fewer tool calls on tasks both solve, pass@1 −4.2 points (noise about ±5); expect base-model
|
|
41
|
+
// behaviour or worse in other agents. Licence lfm1.0 (model and adapter): royalty-free commercial use only below
|
|
42
|
+
// US$10M annual revenue. The model always reasons first on the hand engine (no thinking-off form).
|
|
43
|
+
const LFM2_5_24B_PI = M('LiquidAI/LFM2.5-24B-A2B-GGUF', 'diskformer', 'text-generation', 'chat', {
|
|
42
44
|
budget: 4e9, downloadBytes: 13578190880 + 37437888, experimental: true, license: 'lfm1.0',
|
|
43
45
|
adapter: { repo: 'harness-hub/LFM2.5-24B-A2B-pi-GGUF', file: 'LFM2.5-24B-A2B-harbor-pi-lora-F16.gguf',
|
|
44
46
|
revision: 'a994cf7f03ab0e572832d5829f42cbe2ef28d472', size: 37437888, scale: 1, trainedFor: 'pi' },
|
|
@@ -80,7 +82,7 @@ export const MODELS = {
|
|
|
80
82
|
LFM3_OMNI_2B,
|
|
81
83
|
/** DeepSeek V4.1 Flash (763B MoE), a tech demo on diskformer's llama.cpp path: 231 GB on disk, very slow. */
|
|
82
84
|
DEEPSEEK_V4_1_FLASH,
|
|
83
|
-
/** LFM2.5-24B-A2B with harness-hub's Pi coding-agent LoRA
|
|
85
|
+
/** LFM2.5-24B-A2B with harness-hub's Pi coding-agent LoRA, applied at run time by diskformer's engine; tuned for the Pi agent; licence lfm1.0. */
|
|
84
86
|
LFM2_5_24B_PI,
|
|
85
87
|
},
|
|
86
88
|
/** Chat with images in the messages: content [{ type: 'image', image }, { type: 'text', text }]. Gemma 4 takes audio too. */
|
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.20 (7962912): run-time LoRA in every native MoE engine (engines/lora.js): Gemma 4 26B, Qwen3.6 35B, LFM2.5
|
|
13
|
+
// 24B and Qwen3 (untested); adapters on routed experts are refused. Engines expose addBos from the GGUF.
|
|
14
|
+
// v0.6.19 (cefebc7): run-time LoRA in lfm2_moe_ssd.js (engine.loadAdapter / setAdapter, no reload to switch).
|
|
12
15
|
// v0.6.18 (317d2a0): with thinking off, the llama.cpp chat closes a template's always-open <think> (LFM2.5-24B) and
|
|
13
16
|
// bans the <think> token for the reply.
|
|
14
17
|
// v0.6.17 (314362e): LoRA adapters on the llama.cpp path (llama.load({ lora, loraScale })), and lfm2_moe_ssd.js runs
|
|
@@ -30,7 +33,7 @@ export const PINS = {
|
|
|
30
33
|
// Gemma 4 E2B/E4B and Ternary Bonsai 2) under engines/, and the llama.cpp path under ggml/, which loads split GGUFs
|
|
31
34
|
// and drafters, and reads past 2 GiB (Nemotron 3.5 Bolt E4B needs v0.6.8 or later). The hand engines' ingest
|
|
32
35
|
// streams the GGUF header with progress and resumes a dropped range.
|
|
33
|
-
diskformer: '
|
|
36
|
+
diskformer: '79629125d7a39b13ddcaf74df58262458d8d470e',
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
const gh = (repo, sha, path) => `https://cdn.jsdelivr.net/gh/NakliTechie/${repo}@${sha}/${path}`;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Tool calls for every back end: generate(messages, { tools }) takes OpenAI-style tools ({ type: 'function', function:
|
|
2
|
+
// { name, description, parameters } }), writes them into the conversation in the model family's own format, and reads
|
|
3
|
+
// the calls back out of the reply as OpenAI `tool_calls`. Assistant turns that carry `tool_calls` and `tool` turns that
|
|
4
|
+
// carry results are written back in the same format, so a multi-step tool loop needs no model-specific code.
|
|
5
|
+
//
|
|
6
|
+
// Formats:
|
|
7
|
+
// lfm2 LFM2 / LFM2.5 (architectures lfm2, lfm2moe): the system turn gets "List of tools: <|tool_list_start|>[…]
|
|
8
|
+
// <|tool_list_end|>"; the model answers <|tool_call_start|>[name(arg="v", n=1)]<|tool_call_end|> (Python call
|
|
9
|
+
// syntax); results go back as a `tool` turn. As in LiquidAI's chat template.
|
|
10
|
+
// hermes Qwen and the rest: "# Tools … <tools>{json per line}</tools> …" in the system turn; the model answers
|
|
11
|
+
// <tool_call>{"name": …, "arguments": {…}}</tool_call>; results go back in <tool_response> in a user turn. As
|
|
12
|
+
// in Qwen3's chat template.
|
|
13
|
+
// The parser also reads Qwen3-Coder's XML calls (<tool_call><function=name><parameter=k>v</parameter></function>), which
|
|
14
|
+
// LFM3 writes, whatever the format of the prompt.
|
|
15
|
+
|
|
16
|
+
const LFM2 = new Set(['lfm2', 'lfm2moe']);
|
|
17
|
+
export const toolFormat = (architecture) => (LFM2.has(architecture) ? 'lfm2' : 'hermes');
|
|
18
|
+
|
|
19
|
+
const fn = (t) => t.function || t;
|
|
20
|
+
const text = (c) => (typeof c === 'string' ? c : (c || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n'));
|
|
21
|
+
const args = (a) => (typeof a === 'string' ? (a ? JSON.parse(a) : {}) : a || {});
|
|
22
|
+
|
|
23
|
+
// A Python literal for the lfm2 call syntax.
|
|
24
|
+
function py(v) {
|
|
25
|
+
if (v === null || v === undefined) return 'None';
|
|
26
|
+
if (v === true) return 'True';
|
|
27
|
+
if (v === false) return 'False';
|
|
28
|
+
if (Array.isArray(v)) return `[${v.map(py).join(', ')}]`;
|
|
29
|
+
if (typeof v === 'object') return `{${Object.entries(v).map(([k, x]) => `${JSON.stringify(k)}: ${py(x)}`).join(', ')}}`;
|
|
30
|
+
return JSON.stringify(v);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function callText(calls, format) {
|
|
34
|
+
if (format === 'lfm2') {
|
|
35
|
+
const list = calls.map((c) => `${fn(c).name}(${Object.entries(args(fn(c).arguments)).map(([k, v]) => `${k}=${py(v)}`).join(', ')})`);
|
|
36
|
+
return `<|tool_call_start|>[${list.join(', ')}]<|tool_call_end|>`;
|
|
37
|
+
}
|
|
38
|
+
return calls.map((c) => `<tool_call>\n${JSON.stringify({ name: fn(c).name, arguments: args(fn(c).arguments) })}\n</tool_call>`).join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function systemText(tools, format) {
|
|
42
|
+
const defs = tools.map(fn);
|
|
43
|
+
if (format === 'lfm2') return `List of tools: <|tool_list_start|>${JSON.stringify(defs)}<|tool_list_end|>`;
|
|
44
|
+
return '# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function ' +
|
|
45
|
+
`signatures within <tools></tools> XML tags:\n<tools>\n${defs.map((d) => JSON.stringify({ type: 'function', function: d })).join('\n')}\n</tools>\n\n` +
|
|
46
|
+
'For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n' +
|
|
47
|
+
'<tool_call>\n{"name": <function-name>, "arguments": <args-json-object>}\n</tool_call>';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The conversation with the tool list in its system turn and tool traffic as text, for a back end whose chat template
|
|
51
|
+
// is not given the tools itself.
|
|
52
|
+
export function withTools(messages, tools, format) {
|
|
53
|
+
const out = [];
|
|
54
|
+
const list = tools?.length ? systemText(tools, format) : null;
|
|
55
|
+
const first = messages[0]?.role === 'system' ? messages[0] : null;
|
|
56
|
+
if (list) out.push({ role: 'system', content: first ? `${text(first.content)}\n${list}` : list });
|
|
57
|
+
else if (first) out.push(first);
|
|
58
|
+
for (const m of messages.slice(first ? 1 : 0)) {
|
|
59
|
+
if (m.role === 'assistant' && m.tool_calls?.length) {
|
|
60
|
+
const before = text(m.content);
|
|
61
|
+
out.push({ role: 'assistant', content: `${before ? `${before}\n` : ''}${callText(m.tool_calls, format)}` });
|
|
62
|
+
} else if (m.role === 'tool') {
|
|
63
|
+
const body = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
|
64
|
+
if (format === 'lfm2') out.push({ role: 'tool', content: body });
|
|
65
|
+
else {
|
|
66
|
+
const prev = out.at(-1);
|
|
67
|
+
const wrapped = `<tool_response>\n${body}\n</tool_response>`;
|
|
68
|
+
// Consecutive results share one user turn, as Qwen3's template writes them.
|
|
69
|
+
if (prev?.role === 'user' && prev.toolResults) prev.content += `\n${wrapped}`;
|
|
70
|
+
else out.push(Object.defineProperty({ role: 'user', content: wrapped }, 'toolResults', { value: true }));
|
|
71
|
+
}
|
|
72
|
+
} else out.push(m);
|
|
73
|
+
}
|
|
74
|
+
return out.map(({ role, content }) => ({ role, content }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A Python literal (strings, numbers, True/False/None, lists, dicts) at s[i]: [value, next index].
|
|
78
|
+
function literal(s, i) {
|
|
79
|
+
const ws = () => { while (/\s/.test(s[i])) i++; };
|
|
80
|
+
ws();
|
|
81
|
+
const c = s[i];
|
|
82
|
+
if (c === '"' || c === "'") {
|
|
83
|
+
let out = '';
|
|
84
|
+
for (i++; i < s.length && s[i] !== c; i++) {
|
|
85
|
+
if (s[i] === '\\') { i++; out += ({ n: '\n', t: '\t', r: '\r' })[s[i]] ?? s[i]; } else out += s[i];
|
|
86
|
+
}
|
|
87
|
+
if (s[i] !== c) throw new Error('unterminated string');
|
|
88
|
+
return [out, i + 1];
|
|
89
|
+
}
|
|
90
|
+
if (c === '[' || c === '(') {
|
|
91
|
+
const close = c === '[' ? ']' : ')', arr = [];
|
|
92
|
+
for (i++; ;) {
|
|
93
|
+
ws();
|
|
94
|
+
if (s[i] === close) return [arr, i + 1];
|
|
95
|
+
let v; [v, i] = literal(s, i); arr.push(v); ws();
|
|
96
|
+
if (s[i] === ',') i++;
|
|
97
|
+
else if (s[i] !== close) throw new Error('bad list');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (c === '{') {
|
|
101
|
+
const obj = {};
|
|
102
|
+
for (i++; ;) {
|
|
103
|
+
ws();
|
|
104
|
+
if (s[i] === '}') return [obj, i + 1];
|
|
105
|
+
let k, v; [k, i] = literal(s, i); ws();
|
|
106
|
+
if (s[i++] !== ':') throw new Error('bad dict');
|
|
107
|
+
[v, i] = literal(s, i); obj[k] = v; ws();
|
|
108
|
+
if (s[i] === ',') i++;
|
|
109
|
+
else if (s[i] !== '}') throw new Error('bad dict');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const m = /^(True|False|None|true|false|null|-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/.exec(s.slice(i));
|
|
113
|
+
if (!m) throw new Error(`bad value at ${i}`);
|
|
114
|
+
const t = m[1];
|
|
115
|
+
return [t === 'True' || t === 'true' ? true : t === 'False' || t === 'false' ? false : t === 'None' || t === 'null' ? null : Number(t), i + t.length];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// [name(k=v, …), …] in Python call syntax.
|
|
119
|
+
function pythonCalls(body) {
|
|
120
|
+
const s = body.trim().replace(/^\[/, '').replace(/\]$/, ''), calls = [];
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < s.length) {
|
|
123
|
+
const m = /^\s*,?\s*([A-Za-z_][\w.-]*)\s*\(/.exec(s.slice(i));
|
|
124
|
+
if (!m) break;
|
|
125
|
+
i += m[0].length;
|
|
126
|
+
const a = {};
|
|
127
|
+
for (;;) {
|
|
128
|
+
while (/[\s,]/.test(s[i])) i++;
|
|
129
|
+
if (s[i] === ')') { i++; break; }
|
|
130
|
+
const k = /^([A-Za-z_]\w*)\s*=/.exec(s.slice(i));
|
|
131
|
+
if (!k) throw new Error('bad argument');
|
|
132
|
+
i += k[0].length;
|
|
133
|
+
let v; [v, i] = literal(s, i); a[k[1]] = v;
|
|
134
|
+
}
|
|
135
|
+
calls.push({ name: m[1], arguments: a });
|
|
136
|
+
}
|
|
137
|
+
return calls;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function xmlCall(body) {
|
|
141
|
+
const f = /<function=([^>\s]+)>([\s\S]*?)(?:<\/function>|$)/.exec(body);
|
|
142
|
+
if (!f) return null;
|
|
143
|
+
const a = {};
|
|
144
|
+
for (const p of f[2].matchAll(/<parameter=([^>\s]+)>([\s\S]*?)<\/parameter>/g)) {
|
|
145
|
+
const v = p[2].replace(/^\n/, '').replace(/\n$/, '');
|
|
146
|
+
try { a[p[1]] = JSON.parse(v); } catch { a[p[1]] = v; }
|
|
147
|
+
}
|
|
148
|
+
return { name: f[1], arguments: a };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const PATTERNS = [
|
|
152
|
+
[/<\|tool_call_start\|>([\s\S]*?)<\|tool_call_end\|>/g, (b) => pythonCalls(b)],
|
|
153
|
+
[/<tool_call>([\s\S]*?)<\/tool_call>/g, (b) => {
|
|
154
|
+
const x = xmlCall(b);
|
|
155
|
+
if (x) return [x];
|
|
156
|
+
const j = JSON.parse(b.trim());
|
|
157
|
+
return [{ name: j.name, arguments: args(j.arguments ?? j.parameters) }];
|
|
158
|
+
}],
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
// A bare LFM2 call list ending the reply, for an engine that decodes without special tokens (LocalMind's LFM2 engine
|
|
162
|
+
// drops <|tool_call_start|> and <|tool_call_end|>): "[name(k=v), …]" that parses whole as calls.
|
|
163
|
+
const BARE_LFM2 = /\[\s*[A-Za-z_][\w.-]*\s*\([\s\S]*\)\s*\]\s*$/;
|
|
164
|
+
|
|
165
|
+
// The reply's tool calls as OpenAI tool_calls, and its text without them. A call that does not parse stays in the
|
|
166
|
+
// text. ids are call_<n> in reply order. format 'lfm2' also reads a bare call list that ends the reply.
|
|
167
|
+
export function parseToolCalls(reply, format = null) {
|
|
168
|
+
const calls = [];
|
|
169
|
+
let content = reply;
|
|
170
|
+
for (const [re, read] of PATTERNS) {
|
|
171
|
+
content = content.replace(re, (whole, body) => {
|
|
172
|
+
try { calls.push(...read(body)); return ''; } catch { return whole; }
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const bare = format === 'lfm2' && !calls.length && BARE_LFM2.exec(content);
|
|
176
|
+
if (bare) {
|
|
177
|
+
try {
|
|
178
|
+
const found = pythonCalls(bare[0]);
|
|
179
|
+
if (found.length) { calls.push(...found); content = content.slice(0, bare.index); }
|
|
180
|
+
} catch { /* not a call list: leave the text */ }
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
content: calls.length ? content.trim() : reply,
|
|
184
|
+
toolCalls: calls.map((c, n) => ({ id: `call_${n}`, type: 'function', function: { name: c.name, arguments: JSON.stringify(c.arguments) } })),
|
|
185
|
+
};
|
|
186
|
+
}
|