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 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.19",
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
- async load(model, { budget, maxCtx = 1024, poolBytes, localFile, onProgress, signal }) {
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
- for await (const r of engine.generate(messages, { maxNewTokens, signal, enableThinking: thinking === true }))
141
- yield { text: r.text, token: r.token, tokens: r.tokens };
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 with
34
- // `adapters: true` (diskformer-llama) can apply one, so the others drop out of the plan.
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
- const validMessages = (messages) => Array.isArray(messages) && messages.every((m) => typeof m?.role === 'string' && validContent(m?.content));
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
- function serialize(session) {
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
- for await (const chunk of session.generate(messages, o)) yield chunk;
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 only).
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), on
36
- // diskformer's llama.cpp path, the only back end that applies an adapter. The adapter touches the conv, attention and
37
- // two dense FFN layers only, so expert paging is unchanged. diskformer v0.6.17's gate: 8/9 replies identical to
38
- // llama-server --lora (the path's own baseline without the adapter is 7/9). Authors' numbers on 100 data-analysis tasks:
39
- // 39% fewer tool calls on tasks both solve, pass@1 −4.2 points (noise about ±5); expect base-model behaviour or worse in
40
- // other agents. Licence lfm1.0 (model and adapter): royalty-free commercial use only below US$10M annual revenue.
41
- const LFM2_5_24B_PI = M('LiquidAI/LFM2.5-24B-A2B-GGUF', 'diskformer-llama', 'text-generation', 'chat', {
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 (diskformer's llama.cpp path); tuned for the Pi agent; licence lfm1.0. */
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: '317d2a002d95278e504ed84fab2d0f71dabaa884',
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
+ }