@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
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Reading and writing tool calls the way LFM2 and LFM2.5 write them:
3
+ * Pythonic calls between markers,
4
+ * `<|tool_call_start|>[get_weather(city="Tokyo")]<|tool_call_end|>`.
5
+ *
6
+ * Copied from @tanstack-ai-browser/transformers-js (tool-calls.ts, its LFM2
7
+ * parts), so this package doesn't depend on that one and its worker.
8
+ */
9
+ export const TOOL_CALL_START = "<|tool_call_start|>";
10
+ export const TOOL_CALL_END = "<|tool_call_end|>";
11
+ /** A Python-style or JSON value: "x", 'x', 1.5, True, None, [1, 2]… */
12
+ function parseValue(raw) {
13
+ const s = raw.trim();
14
+ if (/^(["']).*\1$/s.test(s)) {
15
+ try {
16
+ return JSON.parse(s[0] === "'" ? `"${s.slice(1, -1)}"` : s);
17
+ }
18
+ catch {
19
+ return s.slice(1, -1);
20
+ }
21
+ }
22
+ if (s === "True" || s === "true")
23
+ return true;
24
+ if (s === "False" || s === "false")
25
+ return false;
26
+ if (s === "None" || s === "null")
27
+ return null;
28
+ if (s !== "" && !Number.isNaN(Number(s)))
29
+ return Number(s);
30
+ try {
31
+ return JSON.parse(s);
32
+ }
33
+ catch {
34
+ return s;
35
+ }
36
+ }
37
+ /** After a value: the next argument (`, name=`), or the end of the call. */
38
+ const VALUE_END = /^\s*(?:,\s*[A-Za-z_]\w*\s*=|,?\s*\)|$)/;
39
+ /**
40
+ * Where the string starting at `body[start]` ends (the index of its closing
41
+ * quote). The model doesn't escape quotes inside its strings ('JWST's
42
+ * findings'), so a quote only closes the string when the argument ends
43
+ * after it; otherwise it's part of the text.
44
+ */
45
+ function stringEnd(body, start) {
46
+ const quote = body[start];
47
+ let last = -1;
48
+ for (let i = start + 1; i < body.length; i++) {
49
+ if (body[i] === "\\")
50
+ i++;
51
+ else if (body[i] === quote) {
52
+ if (VALUE_END.test(body.slice(i + 1)))
53
+ return i;
54
+ last = i;
55
+ }
56
+ }
57
+ return last;
58
+ }
59
+ /** A Python string literal's text, `\'` and `\"` included. */
60
+ function stringText(raw) {
61
+ const inner = raw.slice(1, -1);
62
+ try {
63
+ const escaped = inner.replace(/\\'/g, "'").replace(/(^|[^\\])"/g, '$1\\"');
64
+ return JSON.parse(`"${escaped}"`);
65
+ }
66
+ catch {
67
+ return inner;
68
+ }
69
+ }
70
+ /** Where a `[…]` or `{…}` starting at `body[start]` ends, strings skipped. */
71
+ function bracketEnd(body, start) {
72
+ let depth = 0;
73
+ for (let i = start; i < body.length; i++) {
74
+ const c = body[i];
75
+ if (c === '"' || c === "'") {
76
+ const end = stringEnd(body, i);
77
+ if (end < 0)
78
+ return -1;
79
+ i = end;
80
+ }
81
+ else if (c === "[" || c === "{")
82
+ depth++;
83
+ else if ((c === "]" || c === "}") && --depth === 0)
84
+ return i;
85
+ }
86
+ return -1;
87
+ }
88
+ /** `[name(a="x", b=2), other()]` → calls. */
89
+ function parsePythonic(body) {
90
+ const calls = [];
91
+ const callStart = /([A-Za-z_][\w.-]*)\(/g;
92
+ let m;
93
+ while ((m = callStart.exec(body))) {
94
+ const args = {};
95
+ let i = m.index + m[0].length;
96
+ for (;;) {
97
+ const key = /^\s*([A-Za-z_]\w*)\s*=\s*/.exec(body.slice(i));
98
+ if (!key)
99
+ break;
100
+ i += key[0].length;
101
+ const first = body[i];
102
+ let end;
103
+ if (first === '"' || first === "'") {
104
+ end = stringEnd(body, i);
105
+ if (end < 0)
106
+ break;
107
+ args[key[1]] = stringText(body.slice(i, end + 1));
108
+ }
109
+ else if (first === "[" || first === "{") {
110
+ end = bracketEnd(body, i);
111
+ if (end < 0)
112
+ break;
113
+ args[key[1]] = parseValue(body.slice(i, end + 1));
114
+ }
115
+ else {
116
+ const rest = /^[^,)]*/.exec(body.slice(i))?.[0] ?? "";
117
+ end = i + rest.length - 1;
118
+ args[key[1]] = parseValue(rest);
119
+ }
120
+ i = end + 1;
121
+ const comma = /^\s*,/.exec(body.slice(i));
122
+ if (!comma)
123
+ break;
124
+ i += comma[0].length;
125
+ }
126
+ calls.push({ name: m[1], args });
127
+ // The next call starts after this one's closing parenthesis.
128
+ const close = body.indexOf(")", i);
129
+ callStart.lastIndex = close < 0 ? body.length : close + 1;
130
+ }
131
+ return calls;
132
+ }
133
+ /** Every tool call in `text`. */
134
+ export function parseToolCalls(text) {
135
+ const calls = [];
136
+ for (const m of text.matchAll(/<\|tool_call_start\|>([\s\S]*?)(?:<\|tool_call_end\|>|$)/g)) {
137
+ calls.push(...parsePythonic(m[1]));
138
+ }
139
+ return calls;
140
+ }
141
+ /** Length of the longest end of `text` that could start one of `markers`. */
142
+ function partialMarker(text, markers) {
143
+ for (let n = Math.min(text.length, 18); n > 0; n--) {
144
+ const end = text.slice(-n);
145
+ if (markers.some((m) => m.startsWith(end)))
146
+ return n;
147
+ }
148
+ return 0;
149
+ }
150
+ /**
151
+ * Splits a streaming answer into the text before the first tool call (passed
152
+ * through as it arrives) and the tool-call markup after it (held back and
153
+ * parsed once the reply ends).
154
+ */
155
+ export function createToolCallSplitter(markers = [TOOL_CALL_START]) {
156
+ let held = "";
157
+ let markup = null;
158
+ /** Text that can be shown now. */
159
+ function push(text) {
160
+ if (markup !== null) {
161
+ markup += text;
162
+ return "";
163
+ }
164
+ held += text;
165
+ const at = Math.min(...markers.map((m) => held.indexOf(m)).filter((i) => i >= 0));
166
+ if (Number.isFinite(at)) {
167
+ const before = held.slice(0, at);
168
+ markup = held.slice(at);
169
+ held = "";
170
+ return before;
171
+ }
172
+ const keep = partialMarker(held, markers);
173
+ const out = held.slice(0, held.length - keep);
174
+ held = held.slice(out.length);
175
+ return out;
176
+ }
177
+ /** The rest of the text, and the tool-call markup if any. */
178
+ function flush() {
179
+ const rest = held;
180
+ held = "";
181
+ return { text: rest, markup };
182
+ }
183
+ return { push, flush };
184
+ }
185
+ /** A value written the way Python (and LFM2.5) writes it. */
186
+ function pythonValue(value) {
187
+ if (value === true)
188
+ return "True";
189
+ if (value === false)
190
+ return "False";
191
+ if (value === null || value === undefined)
192
+ return "None";
193
+ return JSON.stringify(value);
194
+ }
195
+ /** A past tool call in LFM2.5's own format, for the assistant turn's text. */
196
+ export function formatLfm2ToolCalls(calls) {
197
+ const list = calls
198
+ .map((c) => `${c.name}(${Object.entries(c.args)
199
+ .map(([k, v]) => `${k}=${pythonValue(v)}`)
200
+ .join(", ")})`)
201
+ .join(", ");
202
+ return `${TOOL_CALL_START}[${list}]${TOOL_CALL_END}`;
203
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@tanstack-ai-browser/wllama",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "TanStack AI adapter for GGUF models running in the browser with wllama (llama.cpp in WebAssembly)",
5
+ "license": "MIT",
6
+ "author": "daveycodez",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/daveycodez/tanstack-ai-browser.git",
10
+ "directory": "packages/wllama"
11
+ },
12
+ "homepage": "https://github.com/daveycodez/tanstack-ai-browser/tree/main/packages/wllama#readme",
13
+ "bugs": "https://github.com/daveycodez/tanstack-ai-browser/issues",
14
+ "keywords": [
15
+ "tanstack-ai",
16
+ "tanstack",
17
+ "ai",
18
+ "llm",
19
+ "wllama",
20
+ "llama.cpp",
21
+ "gguf",
22
+ "webassembly",
23
+ "browser",
24
+ "local-ai"
25
+ ],
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "main": "./dist/index.js",
29
+ "module": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ "./package.json": "./package.json",
33
+ ".": {
34
+ "@tanstack-ai-browser/source": "./src/index.ts",
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js",
37
+ "default": "./dist/index.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "src",
42
+ "dist",
43
+ "!**/*.tsbuildinfo"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "nx": {
49
+ "name": "wllama"
50
+ },
51
+ "peerDependencies": {
52
+ "@tanstack/ai": "^1.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@tanstack/ai": "1.4.0",
56
+ "esbuild": "0.28.2"
57
+ },
58
+ "dependencies": {
59
+ "@tanstack/openai-base": "^1.0.4",
60
+ "@wllama/wllama": "3.13.0"
61
+ },
62
+ "scripts": {
63
+ "test": "node --import @swc-node/register/esm-register --test \"src/**/*.test.ts\""
64
+ }
6
65
  }
package/src/adapter.ts ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * A TanStack AI text adapter for GGUF models that run in the browser with
3
+ * wllama (llama.cpp in WebAssembly). wllama answers llama.cpp's
4
+ * OpenAI-compatible Chat Completions API in the page, so this is TanStack's
5
+ * Chat Completions adapter with the loaded model as its client: messages,
6
+ * tools, structured output and the stream (text, thinking, tool calls) are
7
+ * TanStack's, and llama.cpp writes the prompt with the model's chat template
8
+ * and reads its thinking and tool calls back. This file adds what's wllama's
9
+ * own: loading the model with the adapter's settings, the chat template's
10
+ * thinking switch, the model card's sampling, and audio parts for the
11
+ * models that read them.
12
+ */
13
+ import type { ReasoningRequest, TextOptions } from "@tanstack/ai"
14
+ import { resolveReasoning } from "@tanstack/ai/adapter-internals"
15
+ import { OpenAIBaseChatCompletionsTextAdapter } from "@tanstack/openai-base"
16
+ import type { OpenAIClientLike } from "@tanstack/openai-base"
17
+ import type { WllamaCompat } from "@wllama/wllama/esm/index.js"
18
+
19
+ import { llamaAudio } from "./audio.js"
20
+ import { availability, loadModel, unloadModel } from "./client.js"
21
+ import type { Availability, DownloadProgressCallback, LoadSettings } from "./client.js"
22
+ import { listed, modelMeta } from "./catalog.js"
23
+ import type { Sampling, WllamaChatModel, WllamaModel, WllamaModelReasoningByName } from "./catalog.js"
24
+
25
+ export type WllamaConfig = {
26
+ /**
27
+ * Most tokens a chat may hold, prompt and reply together: the model's
28
+ * whole context window by default (its GGUF's training context), never
29
+ * more. llama.cpp reserves the attention cache for all of it when the
30
+ * model loads (LFM3: ~12 KB a token, so 3 GB for its 262,144), so set
31
+ * less where memory is short: 8,192 on an iPhone. Changing it loads the
32
+ * model again.
33
+ */
34
+ contextLength?: number
35
+ /**
36
+ * Where wllama's WebAssembly file is served. By default, the installed
37
+ * `@wllama/wllama`'s own file on jsDelivr, at its version; serve
38
+ * `@wllama/wllama/esm/wasm/wllama.wasm` with the site and pass its URL to
39
+ * host it yourself.
40
+ */
41
+ wasmUrl?: string
42
+ /**
43
+ * Where wllama's compat build (for Safari: `@wllama/wllama-compat`'s
44
+ * `wasm/wllama.wasm` and `wasm/wllama.js`) is served, passed to
45
+ * `Wllama.setCompat`. By default, wllama's: the same version on jsDelivr.
46
+ */
47
+ compat?: WllamaCompat
48
+ /** CPU threads. wllama's default otherwise: half the cores. */
49
+ threads?: number
50
+ /**
51
+ * `false` to run on the CPU only. By default wllama runs on WebGPU where
52
+ * it can, and on the CPU elsewhere.
53
+ */
54
+ gpu?: boolean
55
+ /** wllama's and llama.cpp's logs, in the console. */
56
+ debug?: boolean
57
+ /**
58
+ * The model's download progress, 0 to 1, when a call (`chat()`, say)
59
+ * starts its download; then 1 once it's ready. To load it ahead of time
60
+ * with progress, see `create`.
61
+ */
62
+ onDownloadProgress?: DownloadProgressCallback
63
+ }
64
+
65
+ /**
66
+ * Per-call `modelOptions`: llama.cpp's own request fields, sent as given
67
+ * (wllama answers with llama.cpp's OpenAI-compatible server code), over the
68
+ * model card's sampling. These are the common ones; llama.cpp's others go
69
+ * through too.
70
+ */
71
+ export type WllamaTextModelOptions = {
72
+ /** Most tokens to generate, thinking included. llama.cpp's default otherwise: up to the context limit. */
73
+ max_tokens?: number
74
+ /** Randomness of sampling: 0 is greedy. */
75
+ temperature?: number
76
+ /** Nucleus sampling: the likeliest tokens up to this probability, in (0, 1]. */
77
+ top_p?: number
78
+ /** Sampling from the likeliest `top_k` tokens only (0: all of them). */
79
+ top_k?: number
80
+ /** Drops tokens less likely than this share of the likeliest one's odds. */
81
+ min_p?: number
82
+ /** Penalizes tokens already in the chat (1: off). */
83
+ repeat_penalty?: number
84
+ /** Lowers the odds of tokens by how often they've appeared. */
85
+ frequency_penalty?: number
86
+ /** Lowers the odds of tokens that have appeared at all. */
87
+ presence_penalty?: number
88
+ /** Seeds the sampler, for the same samples from the same request. */
89
+ seed?: number
90
+ /** Where to stop: the reply ends before the first of these it would write. */
91
+ stop?: string | Array<string>
92
+ /** Adds to the odds of tokens, by token id. */
93
+ logit_bias?: Record<string, number>
94
+ /**
95
+ * False constrains the model to one tool call. True or left out: as many
96
+ * as the model writes, when its chat template supports several.
97
+ */
98
+ parallel_tool_calls?: boolean
99
+ /** The chat template's own variables; `chat({ reasoning })` sets its `enable_thinking`. */
100
+ chat_template_kwargs?: Record<string, unknown>
101
+ }
102
+
103
+ /** A model's reasoning levels: a listed model's, none for a listed one that doesn't reason, else the switch. */
104
+ type ResolveReasoning<TModel extends string> = TModel extends keyof WllamaModelReasoningByName
105
+ ? WllamaModelReasoningByName[TModel]
106
+ : TModel extends WllamaChatModel
107
+ ? never
108
+ : { levels: "off" | "high"; budget: true }
109
+
110
+ /** The switch for a model the catalog doesn't list: the chat template's `enable_thinking`. */
111
+ const UNLISTED_REASONING = { levels: { off: "false", high: "true" }, budget: true } as const
112
+
113
+ /** A model's reasoning levels: the catalog's, or the switch for one it doesn't list. */
114
+ const reasoningOf = (model: WllamaModel) => (listed(model) ? modelMeta(model).reasoning : UNLISTED_REASONING)
115
+
116
+ type MetadataByModality = { text: unknown; image: unknown; audio: unknown; video: unknown; document: unknown }
117
+
118
+ /** The base adapter's conversion of a message part to a Chat Completions one. */
119
+ type ConvertContentPart = OpenAIBaseChatCompletionsTextAdapter<string>["convertContentPart"]
120
+
121
+ /** A Chat Completions request, as the base adapter writes it. */
122
+ type CreateParams = Parameters<OpenAIClientLike["chat"]["completions"]["create"]>[0]
123
+
124
+ /**
125
+ * The chat template's `enable_thinking` for a request, through llama.cpp's
126
+ * `chat_template_kwargs`, and a thinking budget: left out when the model has
127
+ * no levels or the request doesn't ask, so the template's default applies.
128
+ */
129
+ function thinkingKwargs(
130
+ request: ReasoningRequest | undefined,
131
+ model: WllamaModel,
132
+ kwargs: Record<string, unknown> | undefined
133
+ ): Record<string, unknown> {
134
+ const value = resolveReasoning(request, reasoningOf(model))?.value
135
+ if (value === undefined) return {}
136
+ const thinking = value === "true"
137
+ const budget = request?.budgetTokens
138
+ return {
139
+ chat_template_kwargs: { ...kwargs, enable_thinking: thinking },
140
+ ...(thinking && budget !== undefined && { reasoning_budget_tokens: budget })
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The card's sampling for a request: its thinking settings while the chat
146
+ * template thinks (asked for, or a model that always thinks), else its
147
+ * own. None for a model the catalog doesn't list: llama.cpp's defaults.
148
+ */
149
+ function cardSampling(request: ReasoningRequest | undefined, model: WllamaModel): Partial<Sampling> {
150
+ if (!listed(model)) return {}
151
+ const meta = modelMeta(model)
152
+ const value = resolveReasoning(request, meta.reasoning)?.value
153
+ const thinking = value !== undefined ? value === "true" : !!meta.reasoning && !("off" in meta.reasoning.levels)
154
+ return (thinking && meta.thinkingSampling) || meta.sampling
155
+ }
156
+
157
+ /** What a URL holds: a data URL's bytes, or those the page fetches. */
158
+ const bytesAt = async (url: string, signal?: AbortSignal) => (await fetch(url, { ...(signal && { signal }) })).arrayBuffer()
159
+
160
+ /** A Chat Completions part that names its media by URL. */
161
+ type MediaPart = { type?: string; image_url?: { url: string }; audio_url?: { url: string } }
162
+
163
+ /**
164
+ * A request's messages with each image and audio clip as its bytes: wllama
165
+ * reads them as `{ type: "image", data }` and `{ type: "audio", data }`,
166
+ * where the request has an `image_url` or an `audio_url` (a data URL here,
167
+ * or one the page can fetch). Audio llama.cpp can't decode goes as WAV.
168
+ */
169
+ async function withMediaBytes(messages: unknown, signal?: AbortSignal): Promise<unknown> {
170
+ if (!Array.isArray(messages)) return messages
171
+ return Promise.all(
172
+ messages.map(async (message: { content?: unknown }) => {
173
+ if (!Array.isArray(message.content)) return message
174
+ const content = await Promise.all(
175
+ message.content.map(async (part: MediaPart) => {
176
+ if (part.type === "image_url" && part.image_url) {
177
+ return { type: "image", data: await bytesAt(part.image_url.url, signal) }
178
+ }
179
+ if (part.type === "audio_url" && part.audio_url) {
180
+ return { type: "audio", data: await llamaAudio(await bytesAt(part.audio_url.url, signal)) }
181
+ }
182
+ return part
183
+ })
184
+ )
185
+ return { ...message, content }
186
+ })
187
+ )
188
+ }
189
+
190
+ /**
191
+ * The model as an OpenAI client: each request loads it with the adapter's
192
+ * settings (at once when it's loaded with them) and goes to wllama's
193
+ * `createChatCompletion`.
194
+ */
195
+ function clientFor(model: WllamaModel, settings: LoadSettings, onDownloadProgress?: DownloadProgressCallback): OpenAIClientLike {
196
+ const create = async (body: Record<string, unknown>, options?: { signal?: AbortSignal | null }) => {
197
+ const signal = options?.signal ?? undefined
198
+ const wllama = await loadModel(model, settings, { ...(onDownloadProgress && { onDownloadProgress }), ...(signal && { signal }) })
199
+ const messages = await withMediaBytes(body.messages, signal)
200
+ return wllama.createChatCompletion({ ...body, messages, ...(signal && { abortSignal: signal }) } as never)
201
+ }
202
+ return { baseURL: "wllama:", chat: { completions: { create } } } as unknown as OpenAIClientLike
203
+ }
204
+
205
+ /** The settings a model loads with, from the adapter's config. */
206
+ const loadSettings = ({ contextLength, wasmUrl, compat, threads, gpu, debug }: WllamaConfig): LoadSettings => ({
207
+ ...(contextLength !== undefined && { contextLength }),
208
+ ...(wasmUrl && { wasmUrl }),
209
+ ...(compat && { compat }),
210
+ ...(threads && { threads }),
211
+ ...(gpu !== undefined && { gpu }),
212
+ ...(debug && { debug })
213
+ })
214
+
215
+ export class WllamaTextAdapter<TModel extends WllamaModel> extends OpenAIBaseChatCompletionsTextAdapter<
216
+ TModel,
217
+ WllamaTextModelOptions,
218
+ ReadonlyArray<"text" | "image" | "audio">,
219
+ MetadataByModality,
220
+ ReadonlyArray<string>,
221
+ ResolveReasoning<TModel>
222
+ > {
223
+ override readonly name = "wllama"
224
+ override readonly api = "wllama"
225
+ /** Text, and images or audio for a listed model whose projector reads them. */
226
+ override readonly inputModalities: ReadonlyArray<"text" | "image" | "audio">
227
+
228
+ private readonly settings: LoadSettings
229
+ private readonly onDownloadProgress: DownloadProgressCallback | undefined
230
+
231
+ constructor(config: WllamaConfig, model: TModel) {
232
+ const settings = loadSettings(config)
233
+ super(model, "wllama", clientFor(model, settings, config.onDownloadProgress), { strictFallbackWarning: false })
234
+ this.settings = settings
235
+ this.onDownloadProgress = config.onDownloadProgress
236
+ this.inputModalities = listed(model) ? modelMeta(model).supports.input : ["text"]
237
+ }
238
+
239
+ /**
240
+ * Where the model stands in this browser, with the Prompt API's values:
241
+ * "available" once it's in wllama's storage (or loaded), "downloading"
242
+ * while this page downloads it, else "downloadable"; "unavailable" where
243
+ * wllama can't run.
244
+ */
245
+ availability(): Promise<Availability> {
246
+ return availability(this.model)
247
+ }
248
+
249
+ /**
250
+ * Downloads the model if it isn't stored and loads it with this adapter's
251
+ * settings, with the download's progress, 0 to 1, then 1 once it's ready.
252
+ * Resolves to this adapter; rejects if it can't load (fine to leave
253
+ * unawaited). `chat()` doesn't need it: its first call loads the model.
254
+ */
255
+ create(options: { onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal } = {}): Promise<this> {
256
+ const own = this.onDownloadProgress
257
+ const theirs = options.onDownloadProgress
258
+ const ready = loadModel(this.model, this.settings, {
259
+ onDownloadProgress: (progress) => {
260
+ own?.(progress)
261
+ theirs?.(progress)
262
+ },
263
+ ...(options.signal && { signal: options.signal })
264
+ }).then(() => this)
265
+ ready.catch(() => undefined)
266
+ return ready
267
+ }
268
+
269
+ /** Unloads the model, freeing its memory. It stays in wllama's storage. */
270
+ async destroy(): Promise<void> {
271
+ await unloadModel(this.model)
272
+ }
273
+
274
+ /** The request, with `modelOptions` as given over the model card's sampling, and the chat template's thinking switch. */
275
+ protected override mapOptionsToRequest(options: TextOptions): CreateParams & { stream: true } {
276
+ const modelOptions = options.modelOptions as WllamaTextModelOptions | undefined
277
+ const settings = {
278
+ ...cardSampling(options.reasoning, this.model),
279
+ ...modelOptions,
280
+ ...(options.promptCache?.retention === "none" && { cache_prompt: false })
281
+ }
282
+ const request = super.mapOptionsToRequest({ ...options, modelOptions: settings, reasoning: undefined })
283
+ return Object.assign(request, thinkingKwargs(options.reasoning, this.model, modelOptions?.chat_template_kwargs))
284
+ }
285
+
286
+ /** llama.cpp streams thinking as `reasoning_content`. */
287
+ protected override extractReasoning(chunk: unknown) {
288
+ const text = (chunk as { choices?: Array<{ delta?: { reasoning_content?: string } }> }).choices?.[0]?.delta?.reasoning_content
289
+ return text ? { text } : undefined
290
+ }
291
+
292
+ /**
293
+ * An audio part by its URL, as an image goes: Chat Completions takes
294
+ * audio only as base64 in two formats, and the client reads the bytes of
295
+ * either kind from a URL (a data URL for base64).
296
+ */
297
+ protected override convertContentPart(part: Parameters<ConvertContentPart>[0]): ReturnType<ConvertContentPart> {
298
+ if (part.type !== "audio") return super.convertContentPart(part)
299
+ const { source } = part
300
+ if (source.type === "file") {
301
+ throw new Error("wllama takes audio as data or URLs, not as uploaded files.")
302
+ }
303
+ const url =
304
+ source.type === "data" && !source.value.startsWith("data:")
305
+ ? `data:${source.mimeType || "application/octet-stream"};base64,${source.value}`
306
+ : source.value
307
+ return { type: "audio_url", audio_url: { url } } as unknown as ReturnType<ConvertContentPart>
308
+ }
309
+
310
+ /** Schemas as given: llama.cpp's grammar takes any JSON Schema, without OpenAI's strict-mode rewrite. */
311
+ protected override makeStructuredOutputCompatible(schema: Record<string, any>) {
312
+ return schema
313
+ }
314
+ }
315
+
316
+ /**
317
+ * A TanStack AI text adapter for a GGUF model that wllama runs in the
318
+ * browser.
319
+ *
320
+ * @example
321
+ * ```ts
322
+ * chat({ adapter: wllamaText("unsloth/Qwen3.5-2B-GGUF"), messages, threadId })
323
+ * ```
324
+ */
325
+ export function wllamaText<TModel extends WllamaModel>(
326
+ model: TModel,
327
+ config: WllamaConfig = {}
328
+ ): WllamaTextAdapter<TModel> {
329
+ return new WllamaTextAdapter(config, model)
330
+ }
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict"
2
+ import { afterEach, test } from "node:test"
3
+
4
+ import { decodesInLlama, llamaAudio, wavFile } from "./audio.js"
5
+
6
+ const bytes = (...values: Array<number | string>) =>
7
+ new Uint8Array(values.flatMap((value) => (typeof value === "string" ? [...value].map((c) => c.charCodeAt(0)) : [value]))).buffer
8
+
9
+ const globals = globalThis as { OfflineAudioContext?: unknown }
10
+ afterEach(() => {
11
+ delete globals.OfflineAudioContext
12
+ })
13
+
14
+ test("WAV, FLAC and MP3 files are the ones llama.cpp decodes itself", () => {
15
+ assert.equal(decodesInLlama(bytes("RIFF", 0, 0, 0, 0, "WAVE")), true)
16
+ assert.equal(decodesInLlama(bytes("fLaC", 0, 0, 0, 0)), true)
17
+ assert.equal(decodesInLlama(bytes("ID3", 4, 0, 0)), true)
18
+ // An MP3 frame: MPEG-1 Layer III.
19
+ assert.equal(decodesInLlama(bytes(0xff, 0xfb, 0x90, 0x00)), true)
20
+ // What browsers record: Opus in Ogg or WebM, AAC (ADTS, or in MP4).
21
+ assert.equal(decodesInLlama(bytes("OggS", 0, 2, 0, 0)), false)
22
+ assert.equal(decodesInLlama(bytes(0x1a, 0x45, 0xdf, 0xa3)), false)
23
+ assert.equal(decodesInLlama(bytes(0xff, 0xf1, 0x50, 0x80)), false)
24
+ assert.equal(decodesInLlama(bytes(0, 0, 0, 0x20, "ftypM4A ")), false)
25
+ // A RIFF file that isn't audio (WebP), and one too short to tell.
26
+ assert.equal(decodesInLlama(bytes("RIFF", 0, 0, 0, 0, "WEBP")), false)
27
+ assert.equal(decodesInLlama(bytes("RI")), false)
28
+ })
29
+
30
+ test("samples are written as a mono 16-bit PCM WAV file", () => {
31
+ const file = new DataView(wavFile(new Float32Array([0, 1, -1, 2, 0.5]), 16000))
32
+ const text = (at: number) => String.fromCharCode(...[0, 1, 2, 3].map((i) => file.getUint8(at + i)))
33
+ assert.deepEqual([text(0), text(8), text(12), text(36)], ["RIFF", "WAVE", "fmt ", "data"])
34
+ assert.equal(file.byteLength, 44 + 10)
35
+ assert.equal(file.getUint32(4, true), 36 + 10)
36
+ // PCM, one channel, 16,000 samples a second, 2 bytes each.
37
+ assert.deepEqual(
38
+ [file.getUint16(20, true), file.getUint16(22, true), file.getUint32(24, true), file.getUint32(28, true), file.getUint16(34, true)],
39
+ [1, 1, 16000, 32000, 16]
40
+ )
41
+ assert.equal(file.getUint32(40, true), 10)
42
+ // Full scale either way, a sample past it clipped.
43
+ assert.deepEqual(
44
+ [0, 1, 2, 3, 4].map((i) => file.getInt16(44 + i * 2, true)),
45
+ [0, 32767, -32768, 32767, 16384]
46
+ )
47
+ assert.equal(decodesInLlama(file.buffer), true)
48
+ })
49
+
50
+ test("a file llama.cpp decodes goes as it is; another is decoded by the browser, mixed to mono, and goes as WAV", async () => {
51
+ const wav = wavFile(new Float32Array([0.25]), 8000)
52
+ assert.equal(await llamaAudio(wav), wav)
53
+
54
+ const opus = bytes("OggS", 0, 2, 0, 0)
55
+ await assert.rejects(llamaAudio(opus), /no Web Audio/)
56
+
57
+ let decodedAt = 0
58
+ globals.OfflineAudioContext = class {
59
+ constructor(_channels: number, _length: number, sampleRate: number) {
60
+ decodedAt = sampleRate
61
+ }
62
+ async decodeAudioData(data: ArrayBuffer) {
63
+ // A copy was handed over: the caller's bytes stay usable.
64
+ assert.notEqual(data, opus)
65
+ const channels = [new Float32Array([1, -1, 0.5]), new Float32Array([0, 0, 0.5])]
66
+ return { length: 3, numberOfChannels: 2, sampleRate: 16000, getChannelData: (channel: number) => channels[channel] }
67
+ }
68
+ }
69
+ const file = new DataView(await llamaAudio(opus))
70
+ assert.equal(decodedAt, 16000)
71
+ assert.equal(file.getUint32(24, true), 16000)
72
+ assert.deepEqual(
73
+ [0, 1, 2].map((i) => file.getInt16(44 + i * 2, true)),
74
+ [16384, -16384, 16384]
75
+ )
76
+
77
+ globals.OfflineAudioContext = class {
78
+ async decodeAudioData() {
79
+ throw new DOMException("Unable to decode audio data", "EncodingError")
80
+ }
81
+ }
82
+ await assert.rejects(llamaAudio(opus), /couldn't decode this audio/)
83
+ })