@tanstack-ai-browser/wllama 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/dist/adapter.d.ts +166 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +183 -0
- package/dist/audio.d.ts +16 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +80 -0
- package/dist/catalog.d.ts +104 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +307 -0
- package/dist/client.d.ts +64 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +148 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/model-memory.d.ts +46 -0
- package/dist/model-memory.d.ts.map +1 -0
- package/dist/model-memory.js +79 -0
- package/dist/model-meta.d.ts +7 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/model-meta.js +5 -0
- package/dist/shared-load.d.ts +48 -0
- package/dist/shared-load.d.ts.map +1 -0
- package/dist/shared-load.js +103 -0
- package/dist/store.d.ts +8 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +17 -0
- package/dist/template.d.ts +26 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +25 -0
- package/dist/thinking.d.ts +39 -0
- package/dist/thinking.d.ts.map +1 -0
- package/dist/thinking.js +140 -0
- package/dist/tool-calls.d.ts +33 -0
- package/dist/tool-calls.d.ts.map +1 -0
- package/dist/tool-calls.js +203 -0
- package/package.json +62 -3
- package/src/adapter.ts +330 -0
- package/src/audio.test.ts +83 -0
- package/src/audio.ts +79 -0
- package/src/catalog.ts +399 -0
- package/src/client.ts +195 -0
- package/src/index.ts +11 -0
- package/src/runtime.test.ts +220 -0
- package/src/wllama-version.test.ts +19 -0
- package/src/wrapper.test.ts +173 -0
package/src/audio.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio as llama.cpp reads it. Its multimodal library decodes WAV, MP3 and
|
|
3
|
+
* FLAC files itself, and resamples them to the model's rate. Anything else
|
|
4
|
+
* a browser records or plays (Opus in WebM or Ogg, AAC in MP4) is decoded
|
|
5
|
+
* here, with Web Audio, and handed over as WAV.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The rate other formats are decoded at: the audio encoders' own, so llama.cpp resamples nothing. */
|
|
9
|
+
const SAMPLE_RATE = 16000
|
|
10
|
+
|
|
11
|
+
/** Whether `bytes` holds `text` (ASCII) at `at`. */
|
|
12
|
+
const holds = (bytes: Uint8Array, at: number, text: string) =>
|
|
13
|
+
[...text].every((character, i) => bytes[at + i] === character.charCodeAt(0))
|
|
14
|
+
|
|
15
|
+
/** Whether `data` is a WAV, FLAC or MP3 file, by how it starts: the files llama.cpp decodes. */
|
|
16
|
+
export function decodesInLlama(data: ArrayBuffer): boolean {
|
|
17
|
+
const bytes = new Uint8Array(data, 0, Math.min(data.byteLength, 12))
|
|
18
|
+
if (bytes.length < 4) return false
|
|
19
|
+
if (holds(bytes, 0, "RIFF") && holds(bytes, 8, "WAVE")) return true
|
|
20
|
+
if (holds(bytes, 0, "fLaC")) return true
|
|
21
|
+
// An MP3 that opens with an ID3 tag, or with a frame: 11 sync bits, then
|
|
22
|
+
// Layer III (AAC's ADTS frames open the same way, as layer 0).
|
|
23
|
+
if (holds(bytes, 0, "ID3")) return true
|
|
24
|
+
const [first = 0, second = 0] = bytes
|
|
25
|
+
return first === 0xff && (second & 0xe0) === 0xe0 && (second & 0x06) === 0x02
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Mono samples, each from -1 to 1, as a 16-bit PCM WAV file. */
|
|
29
|
+
export function wavFile(samples: Float32Array, sampleRate: number): ArrayBuffer {
|
|
30
|
+
const size = samples.length * 2
|
|
31
|
+
const file = new DataView(new ArrayBuffer(44 + size))
|
|
32
|
+
const text = (at: number, value: string) => {
|
|
33
|
+
for (let i = 0; i < value.length; i++) file.setUint8(at + i, value.charCodeAt(i))
|
|
34
|
+
}
|
|
35
|
+
text(0, "RIFF")
|
|
36
|
+
file.setUint32(4, 36 + size, true)
|
|
37
|
+
text(8, "WAVE")
|
|
38
|
+
text(12, "fmt ")
|
|
39
|
+
file.setUint32(16, 16, true)
|
|
40
|
+
// PCM, one channel, 2 bytes a sample.
|
|
41
|
+
file.setUint16(20, 1, true)
|
|
42
|
+
file.setUint16(22, 1, true)
|
|
43
|
+
file.setUint32(24, sampleRate, true)
|
|
44
|
+
file.setUint32(28, sampleRate * 2, true)
|
|
45
|
+
file.setUint16(32, 2, true)
|
|
46
|
+
file.setUint16(34, 16, true)
|
|
47
|
+
text(36, "data")
|
|
48
|
+
file.setUint32(40, size, true)
|
|
49
|
+
samples.forEach((value, i) => {
|
|
50
|
+
const sample = Math.max(-1, Math.min(1, value))
|
|
51
|
+
file.setInt16(44 + i * 2, Math.round(sample < 0 ? sample * 0x8000 : sample * 0x7fff), true)
|
|
52
|
+
})
|
|
53
|
+
return file.buffer
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An audio file's bytes as llama.cpp can decode them: as they are for WAV,
|
|
58
|
+
* MP3 and FLAC, else decoded by the browser and written as mono WAV.
|
|
59
|
+
*/
|
|
60
|
+
export async function llamaAudio(data: ArrayBuffer): Promise<ArrayBuffer> {
|
|
61
|
+
if (decodesInLlama(data)) return data
|
|
62
|
+
if (typeof OfflineAudioContext === "undefined") {
|
|
63
|
+
throw new Error("This audio isn't WAV, MP3 or FLAC, and there's no Web Audio here to decode it.")
|
|
64
|
+
}
|
|
65
|
+
let decoded: AudioBuffer
|
|
66
|
+
try {
|
|
67
|
+
// A copy: decoding detaches the buffer it's given.
|
|
68
|
+
decoded = await new OfflineAudioContext(1, 1, SAMPLE_RATE).decodeAudioData(data.slice(0))
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error("The browser couldn't decode this audio. WAV, MP3 and FLAC always work.")
|
|
71
|
+
}
|
|
72
|
+
const mono = new Float32Array(decoded.length)
|
|
73
|
+
for (let channel = 0; channel < decoded.numberOfChannels; channel++) {
|
|
74
|
+
decoded.getChannelData(channel).forEach((sample, i) => {
|
|
75
|
+
mono[i] = (mono[i] ?? 0) + sample / decoded.numberOfChannels
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
return wavFile(mono, decoded.sampleRate)
|
|
79
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The models this package knows: GGUF files on Hugging Face that run on the
|
|
3
|
+
* CPU with wllama, with what each supports, the sampling from its card, and
|
|
4
|
+
* how it writes tool calls.
|
|
5
|
+
*/
|
|
6
|
+
import type { ModelReasoning } from "@tanstack/ai"
|
|
7
|
+
import type { ModelReasoningCapability } from "@tanstack/ai/adapter-internals"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sampling settings from a model's card, in llama.cpp's request fields (the
|
|
11
|
+
* names `modelOptions` takes): used unless a call's `modelOptions` sets them.
|
|
12
|
+
*/
|
|
13
|
+
export type Sampling = {
|
|
14
|
+
temperature: number
|
|
15
|
+
/** Left out where the card names none: llama.cpp's default. */
|
|
16
|
+
top_k?: number
|
|
17
|
+
/** llama.cpp's repetition penalty (`--repeat-penalty`). */
|
|
18
|
+
repeat_penalty: number
|
|
19
|
+
/** Nucleus sampling: the likeliest tokens up to this probability. */
|
|
20
|
+
top_p?: number
|
|
21
|
+
/** Drops tokens less likely than this share of the likeliest one's odds. */
|
|
22
|
+
min_p?: number
|
|
23
|
+
/** Lowers the odds of tokens that have appeared at all. */
|
|
24
|
+
presence_penalty?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type WllamaModelMeta = {
|
|
28
|
+
/** The Hugging Face repository the GGUF file is in. */
|
|
29
|
+
name: string
|
|
30
|
+
/** The GGUF file in the repository. */
|
|
31
|
+
file: string
|
|
32
|
+
/** The projector's GGUF file in the repository, for a model that reads images or audio. */
|
|
33
|
+
mmproj?: string
|
|
34
|
+
supports: {
|
|
35
|
+
/** Text, and images or audio for a model whose projector reads them. */
|
|
36
|
+
input: ReadonlyArray<"text" | "image" | "audio">
|
|
37
|
+
output: ReadonlyArray<"text">
|
|
38
|
+
capabilities: ReadonlyArray<"tools" | "thinking">
|
|
39
|
+
}
|
|
40
|
+
/** Parameter count. */
|
|
41
|
+
size: string
|
|
42
|
+
/** Reasoning levels for `chat({ reasoning })`, read from the chat template. */
|
|
43
|
+
reasoning?: ModelReasoning
|
|
44
|
+
/** Sampling from the model card, used unless `modelOptions` overrides it. */
|
|
45
|
+
sampling: Sampling
|
|
46
|
+
/** The card's sampling while the chat template thinks, where it differs. */
|
|
47
|
+
thinkingSampling?: Sampling
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reasoning shapes, each level's value being the chat template's
|
|
51
|
+
// `enable_thinking`. LFM3's template takes it (thinking by default), and
|
|
52
|
+
// Qwen3.5's (thinking off by default); LFM2.5 Thinking always thinks and
|
|
53
|
+
// takes no switch.
|
|
54
|
+
const THINKING_SWITCH = {
|
|
55
|
+
levels: { off: "false", high: "true" },
|
|
56
|
+
budget: true
|
|
57
|
+
} as const
|
|
58
|
+
const ALWAYS_THINKS = { levels: { high: "true" }, budget: true } as const
|
|
59
|
+
|
|
60
|
+
const TOOLS_AND_THINKING = {
|
|
61
|
+
input: ["text"],
|
|
62
|
+
output: ["text"],
|
|
63
|
+
capabilities: ["tools", "thinking"]
|
|
64
|
+
} as const
|
|
65
|
+
const TOOLS = {
|
|
66
|
+
input: ["text"],
|
|
67
|
+
output: ["text"],
|
|
68
|
+
capabilities: ["tools"]
|
|
69
|
+
} as const
|
|
70
|
+
const IMAGES_AND_TOOLS = {
|
|
71
|
+
input: ["text", "image"],
|
|
72
|
+
output: ["text"],
|
|
73
|
+
capabilities: ["tools"]
|
|
74
|
+
} as const
|
|
75
|
+
const IMAGES_TOOLS_AND_THINKING = {
|
|
76
|
+
input: ["text", "image"],
|
|
77
|
+
output: ["text"],
|
|
78
|
+
capabilities: ["tools", "thinking"]
|
|
79
|
+
} as const
|
|
80
|
+
const AUDIO_AND_TOOLS = {
|
|
81
|
+
input: ["text", "audio"],
|
|
82
|
+
output: ["text"],
|
|
83
|
+
capabilities: ["tools"]
|
|
84
|
+
} as const
|
|
85
|
+
const IMAGES_AUDIO_AND_TOOLS = {
|
|
86
|
+
input: ["text", "image", "audio"],
|
|
87
|
+
output: ["text"],
|
|
88
|
+
capabilities: ["tools"]
|
|
89
|
+
} as const
|
|
90
|
+
const AUDIO = {
|
|
91
|
+
input: ["text", "audio"],
|
|
92
|
+
output: ["text"],
|
|
93
|
+
capabilities: []
|
|
94
|
+
} as const
|
|
95
|
+
|
|
96
|
+
/** LFM3's card: `--temp 0.1 --top-k 50 --repeat-penalty 1.1`. */
|
|
97
|
+
const LFM3_SAMPLING = {
|
|
98
|
+
temperature: 0.1,
|
|
99
|
+
top_k: 50,
|
|
100
|
+
repeat_penalty: 1.1
|
|
101
|
+
} as const satisfies Sampling
|
|
102
|
+
/** The cards' llama.cpp settings: `--temp 0.1 --top-k 50 --repeat-penalty 1.05`. */
|
|
103
|
+
const LFM2_5_SAMPLING = {
|
|
104
|
+
temperature: 0.1,
|
|
105
|
+
top_k: 50,
|
|
106
|
+
repeat_penalty: 1.05
|
|
107
|
+
} as const satisfies Sampling
|
|
108
|
+
/** LFM2.5-VL 450M's and 1.6B's cards: `--temp 0.1 --min-p 0.15 --repeat-penalty 1.05`. */
|
|
109
|
+
const LFM2_5_VL_SAMPLING = {
|
|
110
|
+
temperature: 0.1,
|
|
111
|
+
min_p: 0.15,
|
|
112
|
+
repeat_penalty: 1.05
|
|
113
|
+
} as const satisfies Sampling
|
|
114
|
+
/** LFM2.5-VL 3B's card: `--temp 0.2 --top-k 50 --repeat-penalty 1.0`. */
|
|
115
|
+
const LFM2_5_VL_3B_SAMPLING = {
|
|
116
|
+
temperature: 0.2,
|
|
117
|
+
top_k: 50,
|
|
118
|
+
repeat_penalty: 1
|
|
119
|
+
} as const satisfies Sampling
|
|
120
|
+
/** LFM2.5 Thinking's card: `--temp 0.05 --top-k 50 --repeat-penalty 1.05`. */
|
|
121
|
+
const LFM2_5_THINKING_SAMPLING = {
|
|
122
|
+
temperature: 0.05,
|
|
123
|
+
top_k: 50,
|
|
124
|
+
repeat_penalty: 1.05
|
|
125
|
+
} as const satisfies Sampling
|
|
126
|
+
|
|
127
|
+
/** The audio models' cards: greedy decoding (`--temp 0`) for text. */
|
|
128
|
+
const GREEDY_SAMPLING = {
|
|
129
|
+
temperature: 0,
|
|
130
|
+
repeat_penalty: 1
|
|
131
|
+
} as const satisfies Sampling
|
|
132
|
+
|
|
133
|
+
/** Qwen3.5's card, text tasks without thinking (its default). */
|
|
134
|
+
const QWEN_3_5_SAMPLING = {
|
|
135
|
+
temperature: 1,
|
|
136
|
+
top_k: 20,
|
|
137
|
+
top_p: 1,
|
|
138
|
+
min_p: 0,
|
|
139
|
+
presence_penalty: 2,
|
|
140
|
+
repeat_penalty: 1
|
|
141
|
+
} as const satisfies Sampling
|
|
142
|
+
/** Qwen3.5's card, text tasks with thinking. */
|
|
143
|
+
const QWEN_3_5_THINKING_SAMPLING = { ...QWEN_3_5_SAMPLING, top_p: 0.95, presence_penalty: 1.5 } as const satisfies Sampling
|
|
144
|
+
|
|
145
|
+
// Liquid's models are Q4_K_M: in wllama's WebAssembly build it reads
|
|
146
|
+
// prompts and writes tokens about three times as fast as Q4_0 (llama.cpp
|
|
147
|
+
// has no WebAssembly SIMD kernel for Q4_0 to match its Q4_K one), and
|
|
148
|
+
// faster than Q5_K_M, Q6_K and Q8_0, at 5% more size than Q4_0. LFM2.5
|
|
149
|
+
// 1.2B on an M3 Ultra, 24 threads: Q4_K_M 231 prompt tokens/s and 112
|
|
150
|
+
// tokens/s, Q4_0 (Liquid's QAD build too) 65 and 45.
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Liquid AI's LFM3 1.7B, from its official GGUF repository: thinks by
|
|
154
|
+
* default, or answers straight away with reasoning off. It needs wllama
|
|
155
|
+
* 3.13's llama.cpp, the first published with its architecture.
|
|
156
|
+
*/
|
|
157
|
+
const LFM3_1_7B = {
|
|
158
|
+
name: "LiquidAI/LFM3-1.7B-GGUF",
|
|
159
|
+
file: "LFM3-1.7B-Q4_K_M.gguf",
|
|
160
|
+
supports: TOOLS_AND_THINKING,
|
|
161
|
+
size: "1.7b",
|
|
162
|
+
reasoning: THINKING_SWITCH,
|
|
163
|
+
sampling: LFM3_SAMPLING
|
|
164
|
+
} as const satisfies WllamaModelMeta
|
|
165
|
+
|
|
166
|
+
// The vision models load with their projector in Q8_0, the smallest Liquid
|
|
167
|
+
// publishes: it reads an image as the F16 one does in a third to half the size.
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* LFM3-VL 2B: LFM3 with a vision encoder, from Liquid's GGUF repository.
|
|
171
|
+
* Thinks by default, as LFM3 does. 1.10 GB, and a 203 MB projector.
|
|
172
|
+
*/
|
|
173
|
+
const LFM3_VL_2B = {
|
|
174
|
+
name: "LiquidAI/LFM3-VL-2B-GGUF",
|
|
175
|
+
file: "LFM3-VL-2B-Q4_K_M.gguf",
|
|
176
|
+
mmproj: "mmproj-LFM3-VL-2B-Q8_0.gguf",
|
|
177
|
+
supports: IMAGES_TOOLS_AND_THINKING,
|
|
178
|
+
size: "2b",
|
|
179
|
+
reasoning: THINKING_SWITCH,
|
|
180
|
+
sampling: LFM3_SAMPLING
|
|
181
|
+
} as const satisfies WllamaModelMeta
|
|
182
|
+
|
|
183
|
+
/** LFM2.5-VL 3B, from Liquid's GGUF repository: 1.67 GB, and a 583 MB projector. */
|
|
184
|
+
const LFM2_5_VL_3B = {
|
|
185
|
+
name: "LiquidAI/LFM2.5-VL-3B-GGUF",
|
|
186
|
+
file: "LFM2.5-VL-3B-Q4_K_M.gguf",
|
|
187
|
+
mmproj: "mmproj-LFM2.5-VL-3B-Q8_0.gguf",
|
|
188
|
+
supports: IMAGES_AND_TOOLS,
|
|
189
|
+
size: "3b",
|
|
190
|
+
sampling: LFM2_5_VL_3B_SAMPLING
|
|
191
|
+
} as const satisfies WllamaModelMeta
|
|
192
|
+
|
|
193
|
+
/** LFM2.5-VL 1.6B, from Liquid's GGUF repository: 731 MB, and a 583 MB projector. */
|
|
194
|
+
const LFM2_5_VL_1_6B = {
|
|
195
|
+
name: "LiquidAI/LFM2.5-VL-1.6B-GGUF",
|
|
196
|
+
file: "LFM2.5-VL-1.6B-Q4_K_M.gguf",
|
|
197
|
+
mmproj: "mmproj-LFM2.5-VL-1.6b-Q8_0.gguf",
|
|
198
|
+
supports: IMAGES_AND_TOOLS,
|
|
199
|
+
size: "1.6b",
|
|
200
|
+
sampling: LFM2_5_VL_SAMPLING
|
|
201
|
+
} as const satisfies WllamaModelMeta
|
|
202
|
+
|
|
203
|
+
/** LFM2.5-VL 450M, from Liquid's GGUF repository: 229 MB, and a 103 MB projector. */
|
|
204
|
+
const LFM2_5_VL_450M = {
|
|
205
|
+
name: "LiquidAI/LFM2.5-VL-450M-GGUF",
|
|
206
|
+
file: "LFM2.5-VL-450M-Q4_K_M.gguf",
|
|
207
|
+
mmproj: "mmproj-LFM2.5-VL-450m-Q8_0.gguf",
|
|
208
|
+
supports: IMAGES_AND_TOOLS,
|
|
209
|
+
size: "450m",
|
|
210
|
+
sampling: LFM2_5_VL_SAMPLING
|
|
211
|
+
} as const satisfies WllamaModelMeta
|
|
212
|
+
|
|
213
|
+
// The audio models answer in text here: their speech output needs
|
|
214
|
+
// llama.cpp's speech APIs, which this adapter doesn't call. Their projector
|
|
215
|
+
// is the audio encoder (and, for LFM3-Omni, the vision encoder too), in
|
|
216
|
+
// Q8_0, as their cards pair it with the Q4_K_M model.
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* LFM3-Omni 2B: reads images and audio (speech, or other sound) in one
|
|
220
|
+
* model, from Liquid's GGUF repository. It doesn't reason: every answer
|
|
221
|
+
* opens with an empty thinking block. 1.10 GB, and a 507 MB projector.
|
|
222
|
+
*/
|
|
223
|
+
const LFM3_OMNI_2B = {
|
|
224
|
+
name: "LiquidAI/LFM3-Omni-2B-GGUF",
|
|
225
|
+
file: "LFM3-Omni-2B-Q4_K_M.gguf",
|
|
226
|
+
mmproj: "mmproj-LFM3-Omni-2B-Q8_0.gguf",
|
|
227
|
+
supports: IMAGES_AUDIO_AND_TOOLS,
|
|
228
|
+
size: "2b",
|
|
229
|
+
sampling: GREEDY_SAMPLING
|
|
230
|
+
} as const satisfies WllamaModelMeta
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* LFM3-Audio 2B: reads audio (transcribes and translates speech in 18
|
|
234
|
+
* languages, answers questions about a recording, calls tools from a
|
|
235
|
+
* spoken request), from Liquid's GGUF repository. 1.10 GB, and a 304 MB
|
|
236
|
+
* projector.
|
|
237
|
+
*/
|
|
238
|
+
const LFM3_AUDIO_2B = {
|
|
239
|
+
name: "LiquidAI/LFM3-Audio-2B-GGUF",
|
|
240
|
+
file: "LFM3-Audio-2B-Q4_K_M.gguf",
|
|
241
|
+
mmproj: "mmproj-LFM3-Audio-2B-Q8_0.gguf",
|
|
242
|
+
supports: AUDIO_AND_TOOLS,
|
|
243
|
+
size: "2b",
|
|
244
|
+
sampling: GREEDY_SAMPLING
|
|
245
|
+
} as const satisfies WllamaModelMeta
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* LFM2.5-Audio 1.5B: reads audio, in English, from Liquid's GGUF
|
|
249
|
+
* repository, which has no Q4_K_M: Q4_0, 696 MB, and a 220 MB projector.
|
|
250
|
+
* It calls no tools: told of tools, it answers that none of them can hear
|
|
251
|
+
* a recording, so it's best sent none.
|
|
252
|
+
*/
|
|
253
|
+
const LFM2_5_AUDIO_1_5B = {
|
|
254
|
+
name: "LiquidAI/LFM2.5-Audio-1.5B-GGUF",
|
|
255
|
+
file: "LFM2.5-Audio-1.5B-Q4_0.gguf",
|
|
256
|
+
mmproj: "mmproj-LFM2.5-Audio-1.5B-Q4_0.gguf",
|
|
257
|
+
supports: AUDIO,
|
|
258
|
+
size: "1.5b",
|
|
259
|
+
sampling: GREEDY_SAMPLING
|
|
260
|
+
} as const satisfies WllamaModelMeta
|
|
261
|
+
|
|
262
|
+
/** Liquid AI's LFM2.5 1.2B Instruct, from its official GGUF repository. */
|
|
263
|
+
const LFM2_5_1_2B = {
|
|
264
|
+
name: "LiquidAI/LFM2.5-1.2B-Instruct-GGUF",
|
|
265
|
+
file: "LFM2.5-1.2B-Instruct-Q4_K_M.gguf",
|
|
266
|
+
supports: TOOLS,
|
|
267
|
+
size: "1.2b",
|
|
268
|
+
sampling: LFM2_5_SAMPLING
|
|
269
|
+
} as const satisfies WllamaModelMeta
|
|
270
|
+
|
|
271
|
+
/** LFM2.5 1.2B Thinking: thinks before every answer. */
|
|
272
|
+
const LFM2_5_1_2B_THINKING = {
|
|
273
|
+
name: "LiquidAI/LFM2.5-1.2B-Thinking-GGUF",
|
|
274
|
+
file: "LFM2.5-1.2B-Thinking-Q4_K_M.gguf",
|
|
275
|
+
supports: TOOLS_AND_THINKING,
|
|
276
|
+
size: "1.2b",
|
|
277
|
+
reasoning: ALWAYS_THINKS,
|
|
278
|
+
sampling: LFM2_5_THINKING_SAMPLING
|
|
279
|
+
} as const satisfies WllamaModelMeta
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* LFM2.5 350M: the smallest and quickest, for short answers, extraction and
|
|
283
|
+
* tool calls rather than knowledge or code.
|
|
284
|
+
*/
|
|
285
|
+
const LFM2_5_350M = {
|
|
286
|
+
name: "LiquidAI/LFM2.5-350M-GGUF",
|
|
287
|
+
file: "LFM2.5-350M-Q4_K_M.gguf",
|
|
288
|
+
supports: TOOLS,
|
|
289
|
+
size: "350m",
|
|
290
|
+
sampling: LFM2_5_SAMPLING
|
|
291
|
+
} as const satisfies WllamaModelMeta
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Qwen3.5 2B, in Unsloth's GGUF build (Qwen publishes none): linear-attention
|
|
295
|
+
* layers with a full-attention layer every fourth, so the attention cache
|
|
296
|
+
* stays small. Thinking is off unless turned on. Q4_K_M, as Liquid's: 1.28
|
|
297
|
+
* GB.
|
|
298
|
+
*/
|
|
299
|
+
const QWEN_3_5_2B = {
|
|
300
|
+
name: "unsloth/Qwen3.5-2B-GGUF",
|
|
301
|
+
file: "Qwen3.5-2B-Q4_K_M.gguf",
|
|
302
|
+
supports: TOOLS_AND_THINKING,
|
|
303
|
+
size: "2b",
|
|
304
|
+
reasoning: THINKING_SWITCH,
|
|
305
|
+
sampling: QWEN_3_5_SAMPLING,
|
|
306
|
+
thinkingSampling: QWEN_3_5_THINKING_SAMPLING
|
|
307
|
+
} as const satisfies WllamaModelMeta
|
|
308
|
+
|
|
309
|
+
export const WLLAMA_CHAT_MODELS = [
|
|
310
|
+
LFM3_1_7B.name,
|
|
311
|
+
LFM3_VL_2B.name,
|
|
312
|
+
LFM2_5_VL_3B.name,
|
|
313
|
+
LFM2_5_VL_1_6B.name,
|
|
314
|
+
LFM2_5_VL_450M.name,
|
|
315
|
+
LFM3_OMNI_2B.name,
|
|
316
|
+
LFM3_AUDIO_2B.name,
|
|
317
|
+
LFM2_5_AUDIO_1_5B.name,
|
|
318
|
+
LFM2_5_1_2B.name,
|
|
319
|
+
LFM2_5_1_2B_THINKING.name,
|
|
320
|
+
LFM2_5_350M.name,
|
|
321
|
+
QWEN_3_5_2B.name
|
|
322
|
+
] as const
|
|
323
|
+
|
|
324
|
+
/** A model this package lists. */
|
|
325
|
+
export type WllamaChatModel = (typeof WLLAMA_CHAT_MODELS)[number]
|
|
326
|
+
|
|
327
|
+
/** Each listed model's metadata, by name. */
|
|
328
|
+
export const WLLAMA_MODEL_META: Readonly<
|
|
329
|
+
Record<WllamaChatModel, WllamaModelMeta>
|
|
330
|
+
> = {
|
|
331
|
+
[LFM3_1_7B.name]: LFM3_1_7B,
|
|
332
|
+
[LFM3_VL_2B.name]: LFM3_VL_2B,
|
|
333
|
+
[LFM2_5_VL_3B.name]: LFM2_5_VL_3B,
|
|
334
|
+
[LFM2_5_VL_1_6B.name]: LFM2_5_VL_1_6B,
|
|
335
|
+
[LFM2_5_VL_450M.name]: LFM2_5_VL_450M,
|
|
336
|
+
[LFM3_OMNI_2B.name]: LFM3_OMNI_2B,
|
|
337
|
+
[LFM3_AUDIO_2B.name]: LFM3_AUDIO_2B,
|
|
338
|
+
[LFM2_5_AUDIO_1_5B.name]: LFM2_5_AUDIO_1_5B,
|
|
339
|
+
[LFM2_5_1_2B.name]: LFM2_5_1_2B,
|
|
340
|
+
[LFM2_5_1_2B_THINKING.name]: LFM2_5_1_2B_THINKING,
|
|
341
|
+
[LFM2_5_350M.name]: LFM2_5_350M,
|
|
342
|
+
[QWEN_3_5_2B.name]: QWEN_3_5_2B
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const WLLAMA_REASONING = {
|
|
346
|
+
[LFM3_1_7B.name]: LFM3_1_7B.reasoning,
|
|
347
|
+
[LFM3_VL_2B.name]: LFM3_VL_2B.reasoning,
|
|
348
|
+
[LFM2_5_1_2B_THINKING.name]: LFM2_5_1_2B_THINKING.reasoning,
|
|
349
|
+
[QWEN_3_5_2B.name]: QWEN_3_5_2B.reasoning
|
|
350
|
+
} as const
|
|
351
|
+
|
|
352
|
+
/** The reasoning levels of each listed model that reasons. */
|
|
353
|
+
export type WllamaModelReasoningByName = {
|
|
354
|
+
[K in keyof typeof WLLAMA_REASONING]: ModelReasoningCapability<
|
|
355
|
+
(typeof WLLAMA_REASONING)[K]
|
|
356
|
+
>
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Reasoning data of each listed model that reasons, for
|
|
361
|
+
* `supportedReasoningLevels()`. A model missing here doesn't reason.
|
|
362
|
+
*/
|
|
363
|
+
export const WLLAMA_MODEL_REASONING: Readonly<Record<string, ModelReasoning>> =
|
|
364
|
+
WLLAMA_REASONING
|
|
365
|
+
|
|
366
|
+
/** A listed model's metadata. */
|
|
367
|
+
export const modelMeta = (model: WllamaChatModel): WllamaModelMeta =>
|
|
368
|
+
WLLAMA_MODEL_META[model]
|
|
369
|
+
|
|
370
|
+
/** A model the catalog lists, a GGUF file's URL, or a GGUF file on Hugging Face as `owner/repo/file.gguf`. */
|
|
371
|
+
export type WllamaModel = WllamaChatModel | (string & {})
|
|
372
|
+
|
|
373
|
+
/** Whether the catalog lists `model`. */
|
|
374
|
+
export const listed = (model: string): model is WllamaChatModel =>
|
|
375
|
+
Object.prototype.hasOwnProperty.call(WLLAMA_MODEL_META, model)
|
|
376
|
+
|
|
377
|
+
/** Where a listed model's projector downloads from; none for a model that reads text only. */
|
|
378
|
+
export const mmprojUrl = (model: WllamaModel) => {
|
|
379
|
+
if (!listed(model)) return undefined
|
|
380
|
+
const { name, mmproj } = modelMeta(model)
|
|
381
|
+
return mmproj && `https://huggingface.co/${name}/resolve/main/${mmproj}`
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Where a model's GGUF file downloads from: a listed model's file, or the
|
|
386
|
+
* named one, at its latest version (main) on Hugging Face.
|
|
387
|
+
*/
|
|
388
|
+
export const modelUrl = (model: WllamaModel) => {
|
|
389
|
+
if (listed(model)) {
|
|
390
|
+
const { name, file } = modelMeta(model)
|
|
391
|
+
return `https://huggingface.co/${name}/resolve/main/${file}`
|
|
392
|
+
}
|
|
393
|
+
if (/^https?:\/\//.test(model)) return model
|
|
394
|
+
const [owner, repo, ...path] = model.split("/")
|
|
395
|
+
if (!owner || !repo || path.length === 0 || !model.endsWith(".gguf")) {
|
|
396
|
+
throw new Error(`${model} isn't a model this package lists: name its GGUF file, as "owner/repo/file.gguf" or a URL.`)
|
|
397
|
+
}
|
|
398
|
+
return `https://huggingface.co/${owner}/${repo}/resolve/main/${path.join("/")}`
|
|
399
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's side of wllama: llama.cpp compiled to WebAssembly, which runs
|
|
3
|
+
* in wllama's own Web Worker. One model at a time: loading another frees
|
|
4
|
+
* the previous one. wllama downloads models to the Origin Private File
|
|
5
|
+
* System once, and loads them from there afterwards.
|
|
6
|
+
*
|
|
7
|
+
* wllama is the official `@wllama/wllama` package, unmodified, used through
|
|
8
|
+
* its public API. Its package has no `exports` and its `main` names a file
|
|
9
|
+
* it doesn't ship, so its built ES module is imported by path.
|
|
10
|
+
*/
|
|
11
|
+
import { LoggerWithoutDebug, ModelManager, Wllama } from "@wllama/wllama/esm/index.js"
|
|
12
|
+
import type { WllamaCompat } from "@wllama/wllama/esm/index.js"
|
|
13
|
+
|
|
14
|
+
import { mmprojUrl, modelUrl } from "./catalog.js"
|
|
15
|
+
import type { WllamaModel } from "./catalog.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where a model stands in this browser, with the Prompt API's values
|
|
19
|
+
* (`LanguageModel.availability()`).
|
|
20
|
+
*/
|
|
21
|
+
export type Availability = "unavailable" | "downloadable" | "downloading" | "available"
|
|
22
|
+
|
|
23
|
+
/** A download's progress, from 0 to 1. */
|
|
24
|
+
export type DownloadProgressCallback = (progress: number) => void
|
|
25
|
+
|
|
26
|
+
/** What a model loads with. A load with other settings loads the model again. */
|
|
27
|
+
export type LoadSettings = {
|
|
28
|
+
/** Most tokens a chat may hold; undefined for the model's training context. */
|
|
29
|
+
contextLength?: number
|
|
30
|
+
/** Where wllama's WebAssembly file is served (`WLLAMA_WASM_URL` by default). */
|
|
31
|
+
wasmUrl?: string
|
|
32
|
+
/** wllama's compat build, for browsers without JSPI (Safari), passed to `Wllama.setCompat`. */
|
|
33
|
+
compat?: WllamaCompat
|
|
34
|
+
/** CPU threads; wllama's default otherwise. */
|
|
35
|
+
threads?: number
|
|
36
|
+
/** `false` to run on the CPU only. */
|
|
37
|
+
gpu?: boolean
|
|
38
|
+
/** wllama's and llama.cpp's logs, in the console. */
|
|
39
|
+
debug?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The version of `@wllama/wllama` this package depends on. */
|
|
43
|
+
export const WLLAMA_VERSION = "3.13.0"
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* wllama's WebAssembly file on jsDelivr, for `WLLAMA_VERSION`: the URL of
|
|
47
|
+
* wllama's own `WasmFromCDN`. It has to match wllama's JavaScript, so it's
|
|
48
|
+
* the same version.
|
|
49
|
+
*/
|
|
50
|
+
export const WLLAMA_WASM_URL = `https://cdn.jsdelivr.net/npm/@wllama/wllama@${WLLAMA_VERSION}/esm/wasm/wllama.wasm`
|
|
51
|
+
|
|
52
|
+
/** The model wllama holds (or is loading). */
|
|
53
|
+
let loaded: {
|
|
54
|
+
model: WllamaModel
|
|
55
|
+
key: string
|
|
56
|
+
wllama: Wllama
|
|
57
|
+
promise: Promise<Wllama>
|
|
58
|
+
ready: boolean
|
|
59
|
+
/** Stops its download. */
|
|
60
|
+
abort: AbortController
|
|
61
|
+
/** The waiting callers' progress callbacks. */
|
|
62
|
+
listeners: Set<DownloadProgressCallback>
|
|
63
|
+
} | null = null
|
|
64
|
+
|
|
65
|
+
/** Models whose file is downloading in this page. */
|
|
66
|
+
const downloading = new Set<WllamaModel>()
|
|
67
|
+
|
|
68
|
+
/** Waits for `promise` until `signal` aborts; the load goes on. */
|
|
69
|
+
const abortable = <T>(promise: Promise<T>, signal: AbortSignal | undefined) =>
|
|
70
|
+
signal
|
|
71
|
+
? Promise.race([
|
|
72
|
+
promise,
|
|
73
|
+
new Promise<never>((_, reject) => {
|
|
74
|
+
if (signal.aborted) reject(signal.reason)
|
|
75
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true })
|
|
76
|
+
})
|
|
77
|
+
])
|
|
78
|
+
: promise
|
|
79
|
+
|
|
80
|
+
/** Ends of unloaded wllamas, in turn: a new load starts once the previous one has ended. */
|
|
81
|
+
let teardown: Promise<void> = Promise.resolve()
|
|
82
|
+
|
|
83
|
+
/** Frees the loaded model, stopping its download. Given a model, only if it's the one loaded. */
|
|
84
|
+
export async function unloadModel(model?: WllamaModel) {
|
|
85
|
+
const current = loaded
|
|
86
|
+
if (!current || (model !== undefined && current.model !== model)) return
|
|
87
|
+
loaded = null
|
|
88
|
+
downloading.delete(current.model)
|
|
89
|
+
current.abort.abort()
|
|
90
|
+
teardown = teardown.then(() => current.wllama.exit()).catch(() => undefined)
|
|
91
|
+
await teardown
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The model, loaded with `settings` (downloading it the first time), in
|
|
96
|
+
* place of any other. `onDownloadProgress` hears the download, 0 to 1,
|
|
97
|
+
* then 1 once the model is ready. Aborting `signal` stops this caller's
|
|
98
|
+
* wait; `unloadModel` stops the load.
|
|
99
|
+
*/
|
|
100
|
+
export async function loadModel(
|
|
101
|
+
model: WllamaModel,
|
|
102
|
+
settings: LoadSettings,
|
|
103
|
+
{ onDownloadProgress, signal }: { onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal } = {}
|
|
104
|
+
): Promise<Wllama> {
|
|
105
|
+
signal?.throwIfAborted()
|
|
106
|
+
// A name that isn't a model throws here, before anything is unloaded.
|
|
107
|
+
const url = modelUrl(model)
|
|
108
|
+
const projector = mmprojUrl(model)
|
|
109
|
+
const key = JSON.stringify(settings)
|
|
110
|
+
if (loaded?.model !== model || loaded.key !== key) {
|
|
111
|
+
// Published at once, so a call meanwhile shares this load.
|
|
112
|
+
void unloadModel()
|
|
113
|
+
const wllama = new Wllama(
|
|
114
|
+
{ default: settings.wasmUrl ?? WLLAMA_WASM_URL },
|
|
115
|
+
{ logger: settings.debug ? console : LoggerWithoutDebug, suppressNativeLog: !settings.debug }
|
|
116
|
+
)
|
|
117
|
+
if (settings.compat) wllama.setCompat(settings.compat)
|
|
118
|
+
const abort = new AbortController()
|
|
119
|
+
// This caller's callback first: wllama may report progress at once.
|
|
120
|
+
const listeners = new Set<DownloadProgressCallback>(onDownloadProgress ? [onDownloadProgress] : [])
|
|
121
|
+
const promise = teardown
|
|
122
|
+
.then(() => {
|
|
123
|
+
abort.signal.throwIfAborted()
|
|
124
|
+
return wllama.loadModelFromUrl(projector ? { url, mmprojUrl: projector } : url, {
|
|
125
|
+
signal: abort.signal,
|
|
126
|
+
progressCallback: ({ loaded: bytes, total }) => {
|
|
127
|
+
// A model in storage reports its whole size once, without downloading.
|
|
128
|
+
if (!total || bytes >= total) return
|
|
129
|
+
downloading.add(model)
|
|
130
|
+
for (const listener of listeners) listener(bytes / total)
|
|
131
|
+
},
|
|
132
|
+
// 0: llama.cpp's "the model's training context" (wllama's default is 1,024).
|
|
133
|
+
n_ctx: settings.contextLength ?? 0,
|
|
134
|
+
// The whole context in sliding-window layers' cache (LFM3's), so a
|
|
135
|
+
// follow-up reuses what was read before.
|
|
136
|
+
swa_full: true,
|
|
137
|
+
...(settings.gpu === false && { n_gpu_layers: 0 }),
|
|
138
|
+
...(settings.threads && { n_threads: settings.threads })
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
.then(() => {
|
|
142
|
+
// Unloaded meanwhile: a load that ends anyway (from storage) is stale.
|
|
143
|
+
abort.signal.throwIfAborted()
|
|
144
|
+
return wllama
|
|
145
|
+
})
|
|
146
|
+
.finally(() => downloading.delete(model))
|
|
147
|
+
const current = { model, key, wllama, promise, ready: false, abort, listeners }
|
|
148
|
+
loaded = current
|
|
149
|
+
promise.then(
|
|
150
|
+
() => {
|
|
151
|
+
current.ready = true
|
|
152
|
+
},
|
|
153
|
+
() => {
|
|
154
|
+
if (loaded === current) void unloadModel(model)
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
const current = loaded
|
|
159
|
+
if (onDownloadProgress) current.listeners.add(onDownloadProgress)
|
|
160
|
+
try {
|
|
161
|
+
const wllama = await abortable(current.promise, signal)
|
|
162
|
+
onDownloadProgress?.(1)
|
|
163
|
+
return wllama
|
|
164
|
+
} finally {
|
|
165
|
+
if (onDownloadProgress) current.listeners.delete(onDownloadProgress)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** wllama's model storage, read from the page (the Origin Private File System). */
|
|
170
|
+
let storage: ModelManager | undefined
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Where `model` stands in this browser, with the Prompt API's values:
|
|
174
|
+
* "unavailable" where wllama can't run (no WebAssembly, Web Worker or
|
|
175
|
+
* Origin Private File System), "downloading" while this page downloads it,
|
|
176
|
+
* "available" when it's loaded or its file is in wllama's storage, else
|
|
177
|
+
* "downloadable". It reads storage only.
|
|
178
|
+
*/
|
|
179
|
+
export async function availability(model: WllamaModel): Promise<Availability> {
|
|
180
|
+
const runs =
|
|
181
|
+
typeof WebAssembly !== "undefined" &&
|
|
182
|
+
typeof Worker !== "undefined" &&
|
|
183
|
+
typeof navigator !== "undefined" &&
|
|
184
|
+
typeof navigator.storage?.getDirectory === "function"
|
|
185
|
+
if (!runs) return "unavailable"
|
|
186
|
+
if (downloading.has(model)) return "downloading"
|
|
187
|
+
if (loaded?.model === model && loaded.ready) return "available"
|
|
188
|
+
try {
|
|
189
|
+
storage ??= new ModelManager()
|
|
190
|
+
const saved = await storage.getModels()
|
|
191
|
+
return saved.some((m) => m.url === modelUrl(model)) ? "available" : "downloadable"
|
|
192
|
+
} catch {
|
|
193
|
+
return "downloadable"
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TanStack AI adapter for GGUF models that run in the browser with wllama
|
|
3
|
+
* (llama.cpp in WebAssembly). The model catalog (model-meta.ts) is exported
|
|
4
|
+
* here too.
|
|
5
|
+
*/
|
|
6
|
+
export { wllamaText, WllamaTextAdapter } from "./adapter.js"
|
|
7
|
+
export type { WllamaConfig, WllamaTextModelOptions } from "./adapter.js"
|
|
8
|
+
export type { Availability, DownloadProgressCallback } from "./client.js"
|
|
9
|
+
export type { WllamaModel } from "./catalog.js"
|
|
10
|
+
export { WLLAMA_CHAT_MODELS, WLLAMA_MODEL_META, WLLAMA_MODEL_REASONING } from "./catalog.js"
|
|
11
|
+
export type { WllamaChatModel, WllamaModelMeta, WllamaModelReasoningByName } from "./catalog.js"
|