@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -2
  3. package/dist/adapter.d.ts +166 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +183 -0
  6. package/dist/audio.d.ts +16 -0
  7. package/dist/audio.d.ts.map +1 -0
  8. package/dist/audio.js +80 -0
  9. package/dist/catalog.d.ts +104 -0
  10. package/dist/catalog.d.ts.map +1 -0
  11. package/dist/catalog.js +307 -0
  12. package/dist/client.d.ts +64 -0
  13. package/dist/client.d.ts.map +1 -0
  14. package/dist/client.js +148 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +7 -0
  18. package/dist/model-memory.d.ts +46 -0
  19. package/dist/model-memory.d.ts.map +1 -0
  20. package/dist/model-memory.js +79 -0
  21. package/dist/model-meta.d.ts +7 -0
  22. package/dist/model-meta.d.ts.map +1 -0
  23. package/dist/model-meta.js +5 -0
  24. package/dist/shared-load.d.ts +48 -0
  25. package/dist/shared-load.d.ts.map +1 -0
  26. package/dist/shared-load.js +103 -0
  27. package/dist/store.d.ts +8 -0
  28. package/dist/store.d.ts.map +1 -0
  29. package/dist/store.js +17 -0
  30. package/dist/template.d.ts +26 -0
  31. package/dist/template.d.ts.map +1 -0
  32. package/dist/template.js +25 -0
  33. package/dist/thinking.d.ts +39 -0
  34. package/dist/thinking.d.ts.map +1 -0
  35. package/dist/thinking.js +140 -0
  36. package/dist/tool-calls.d.ts +33 -0
  37. package/dist/tool-calls.d.ts.map +1 -0
  38. package/dist/tool-calls.js +203 -0
  39. package/package.json +62 -3
  40. package/src/adapter.ts +330 -0
  41. package/src/audio.test.ts +83 -0
  42. package/src/audio.ts +79 -0
  43. package/src/catalog.ts +399 -0
  44. package/src/client.ts +195 -0
  45. package/src/index.ts +11 -0
  46. package/src/runtime.test.ts +220 -0
  47. package/src/wllama-version.test.ts +19 -0
  48. 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"