@tanstack-ai-browser/webgllm 0.0.0-stage → 0.2.1

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/src/adapter.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * A TanStack AI text adapter for webgllm: LFM3, LFM2.5 and Qwen3.5 on
3
+ * WebGL 2 in a Web Worker, for browsers without WebGPU, reading images
4
+ * where the model has a vision encoder. webgllm answers
5
+ * OpenAI's Chat Completions API in the page, so this is TanStack's Chat
6
+ * Completions adapter with the page's `WebGLLM` instance as its client (or
7
+ * the one for the adapter's own `worker`):
8
+ * messages, tools, structured output and the stream (text, thinking, tool
9
+ * calls) are TanStack's. This file adds what's webgllm's own: loading the
10
+ * model before a call, and its reasoning levels and thinking budget.
11
+ */
12
+ import type { AdapterYieldChunk, ModelReasoning, TextOptions, TokenUsage } from "@tanstack/ai"
13
+ import { resolveReasoning } from "@tanstack/ai/adapter-internals"
14
+ import type { StructuredOutputOptions, StructuredOutputResult } from "@tanstack/ai/adapters"
15
+ import { OpenAIBaseChatCompletionsTextAdapter } from "@tanstack/openai-base"
16
+ import type { OpenAIClientLike } from "@tanstack/openai-base"
17
+ import { WebGLLM } from "webgllm"
18
+ import type { ChatCompletionCreateParamsBase, LoadProgress } from "webgllm"
19
+
20
+ import { modelMeta, WEBGLLM_MODEL_META } from "./catalog.js"
21
+ import type { WebGllmChatModel, WebGllmModelReasoningByName } from "./catalog.js"
22
+
23
+ /**
24
+ * Where a model stands in this browser, with the Prompt API's values
25
+ * (`LanguageModel.availability()`).
26
+ */
27
+ export type Availability = "unavailable" | "downloadable" | "downloading" | "available"
28
+
29
+ /** A download's progress, from 0 to 1. */
30
+ export type DownloadProgressCallback = (progress: number) => void
31
+
32
+ /** A `WebGLLM` (running in `worker`, if given), and the models downloading in it, from its events. */
33
+ function createEngine(worker?: Worker) {
34
+ const webgllm = new WebGLLM(worker && { worker })
35
+ const downloading = new Set<string>()
36
+ webgllm.addEventListener("progress", ({ model, phase, cached }) => {
37
+ if (phase === "download" && !cached) downloading.add(model)
38
+ })
39
+ for (const type of ["load", "error", "unload"] as const) {
40
+ webgllm.addEventListener(type, ({ model }) => downloading.delete(model))
41
+ }
42
+ return { webgllm, downloading }
43
+ }
44
+
45
+ type Engine = ReturnType<typeof createEngine>
46
+
47
+ /** The page's engine, shared by every adapter without a worker of its own. */
48
+ const pageEngine = createEngine()
49
+
50
+ /** The page's `WebGLLM`. Exported for tests. */
51
+ export const webgllm = pageEngine.webgllm
52
+
53
+ /** The engines in the callers' own workers: one a worker, as each worker's handler holds one model. */
54
+ const workerEngines = new WeakMap<Worker, Engine>()
55
+
56
+ /** An adapter's engine: its own worker's (shared by every adapter given that worker), else the page's. */
57
+ function engineFor(worker?: Worker): Engine {
58
+ if (!worker) return pageEngine
59
+ let engine = workerEngines.get(worker)
60
+ if (!engine) workerEngines.set(worker, (engine = createEngine(worker)))
61
+ return engine
62
+ }
63
+
64
+ let supported: boolean | undefined
65
+ /** Whether the engine runs here, checked once: webgllm's check makes a WebGL context. */
66
+ const engineRuns = () => (supported ??= WebGLLM.isSupported())
67
+
68
+ /** The download's share of webgllm's load progress (downloading is its first 90%). */
69
+ const downloadShare = ({ progress, loadedBytes, totalBytes }: LoadProgress) =>
70
+ totalBytes ? Math.min((loadedBytes ?? 0) / totalBytes, 1) : Math.min(progress / 0.9, 1)
71
+
72
+ /**
73
+ * Per-call `modelOptions`: webgllm's own request fields (OpenAI's names, and
74
+ * its `top_k`, `min_p`, `repetition_penalty` and `repair_tool_calls`), sent
75
+ * as given. The fields the adapter writes from `chat()`'s own options
76
+ * (messages, tools, the output schema, streaming, reasoning) are left out.
77
+ */
78
+ export type WebGllmTextModelOptions = Omit<
79
+ ChatCompletionCreateParamsBase,
80
+ | "model"
81
+ | "messages"
82
+ | "stream"
83
+ | "stream_options"
84
+ | "tools"
85
+ | "tool_choice"
86
+ | "response_format"
87
+ | "reasoning_effort"
88
+ | "reasoning"
89
+ >
90
+
91
+ /** Options for `webgllmText`. */
92
+ export type WebGllmConfig = {
93
+ /**
94
+ * Most tokens a chat may hold, prompt and reply together: the model's
95
+ * whole context length by default, never more. A smaller limit takes less
96
+ * memory and refuses longer chats sooner. Changing it loads the model again.
97
+ */
98
+ contextLength?: number
99
+ /**
100
+ * A Web Worker of your own to run the model in, in place of the ones
101
+ * webgllm starts (webgllm's `worker` option): a module whose `onmessage`
102
+ * goes to webgllm's `WebGLLMWorkerHandler` (exported here too). It holds
103
+ * one model, so adapters given the same worker share it and loading
104
+ * another model replaces the one there; `destroy()` frees the model and
105
+ * leaves the worker running.
106
+ */
107
+ worker?: Worker
108
+ /**
109
+ * The model's download progress, 0 to 1, when a call (`chat()`, say)
110
+ * starts its download; then 1 once it's ready. To load it ahead of time
111
+ * with progress, see `create`.
112
+ */
113
+ onDownloadProgress?: DownloadProgressCallback
114
+ }
115
+
116
+ /** A model's reasoning levels: a listed model's, none for a listed one that doesn't reason, else the switch. */
117
+ type ResolveReasoning<TModel extends string> = TModel extends keyof WebGllmModelReasoningByName
118
+ ? WebGllmModelReasoningByName[TModel]
119
+ : TModel extends WebGllmChatModel
120
+ ? never
121
+ : { levels: "off" | "high"; budget: false }
122
+
123
+ /** A model the catalog lists, any Hugging Face repo webgllm can resolve, or the URL of a `.gguf` file or `.onnx` graph. */
124
+ export type WebGllmModel = WebGllmChatModel | (string & {})
125
+
126
+ /** The switch for a model the catalog doesn't list: webgllm's `reasoning_effort`, a hint it ignores where it can't apply. */
127
+ const UNLISTED_REASONING = { levels: { off: "none", high: "high" }, budget: false } as const
128
+
129
+ const listed = (model: string): model is WebGllmChatModel => Object.prototype.hasOwnProperty.call(WEBGLLM_MODEL_META, model)
130
+
131
+ type MetadataByModality = { text: unknown; image: unknown; audio: unknown; video: unknown; document: unknown }
132
+
133
+ /** A Chat Completions request, as the base adapter writes it. */
134
+ type CreateParams = Parameters<OpenAIClientLike["chat"]["completions"]["create"]>[0]
135
+
136
+ const signalOf = (options: TextOptions) =>
137
+ options.abortController?.signal ?? (options.request as RequestInit | undefined)?.signal ?? undefined
138
+
139
+ export class WebGllmTextAdapter<TModel extends WebGllmModel> extends OpenAIBaseChatCompletionsTextAdapter<
140
+ TModel,
141
+ WebGllmTextModelOptions,
142
+ ReadonlyArray<"text" | "image">,
143
+ MetadataByModality,
144
+ ReadonlyArray<string>,
145
+ ResolveReasoning<TModel>
146
+ > {
147
+ override readonly name = "webgllm"
148
+ override readonly api = "webgllm"
149
+ /**
150
+ * What the model reads, from webgllm's catalog: images too for a model
151
+ * with a vision encoder (Qwen3.5's ONNX build). Not known for a model the
152
+ * catalog doesn't list (webgllm's `models.retrieve()` says, once loaded).
153
+ */
154
+ override readonly inputModalities: ReadonlyArray<"text" | "image"> | undefined
155
+
156
+ private readonly contextLength: number | undefined
157
+ private readonly onDownloadProgress: DownloadProgressCallback | undefined
158
+ /** The `WebGLLM` the model loads in: the page's, or the adapter's own worker's. */
159
+ private readonly engine: Engine
160
+
161
+ constructor(config: WebGllmConfig, model: TModel) {
162
+ const engine = engineFor(config.worker)
163
+ // Chat Completions (`chat.completions.create`) is all the adapter calls.
164
+ super(model, "webgllm", engine.webgllm as unknown as OpenAIClientLike, { strictFallbackWarning: false })
165
+ this.engine = engine
166
+ this.contextLength = config.contextLength
167
+ this.onDownloadProgress = config.onDownloadProgress
168
+ this.inputModalities = listed(model) ? modelMeta(model).supports.input : undefined
169
+ }
170
+
171
+ /**
172
+ * Where the model stands in this browser, with the Prompt API's values:
173
+ * "unavailable" where the engine can't run (WebGL 2 with float render
174
+ * targets, in a worker), "downloading" while this page downloads it,
175
+ * "available" when it's loaded or its files are in webgllm's storage,
176
+ * else "downloadable". It reads storage only.
177
+ */
178
+ async availability(): Promise<Availability> {
179
+ const { webgllm, downloading } = this.engine
180
+ if (!engineRuns()) return "unavailable"
181
+ if (downloading.has(this.model)) return "downloading"
182
+ const stored = await webgllm.models.downloaded(this.model).catch(() => false)
183
+ return stored ? "available" : "downloadable"
184
+ }
185
+
186
+ /**
187
+ * Loads the model for this adapter's `contextLength` (downloading it the
188
+ * first time), with the download's progress, 0 to 1, then 1 once it's
189
+ * ready. Resolves to this adapter; rejects if it can't load (fine to leave
190
+ * unawaited). `chat()` doesn't need it: its first call loads the model.
191
+ */
192
+ create(options: { onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal } = {}): Promise<this> {
193
+ const ready = this.load(options.signal, options.onDownloadProgress).then(() => this)
194
+ ready.catch(() => undefined)
195
+ return ready
196
+ }
197
+
198
+ /** Unloads the model, freeing its GPU memory. Its files stay. Never rejects. */
199
+ destroy(): Promise<void> {
200
+ return this.engine.webgllm.unloadModel(this.model).catch(() => undefined)
201
+ }
202
+
203
+ /**
204
+ * Loads the model for this adapter's context length; webgllm returns at
205
+ * once when it's loaded for it, and shares a load between its callers.
206
+ */
207
+ private async load(signal?: AbortSignal | null, onDownloadProgress?: DownloadProgressCallback) {
208
+ const report = (progress: number) => {
209
+ this.onDownloadProgress?.(progress)
210
+ onDownloadProgress?.(progress)
211
+ }
212
+ await this.engine.webgllm.loadModel(this.model, {
213
+ ...(this.contextLength !== undefined && { contextLength: this.contextLength }),
214
+ ...(signal && { signal }),
215
+ onProgress: (progress) => {
216
+ if (progress.phase === "download" && !progress.cached) report(downloadShare(progress))
217
+ }
218
+ })
219
+ report(1)
220
+ }
221
+
222
+ override async *chatStream(options: TextOptions<WebGllmTextModelOptions>): AsyncIterable<AdapterYieldChunk> {
223
+ await this.load(signalOf(options))
224
+ yield* super.chatStream(options)
225
+ }
226
+
227
+ override async *structuredOutputStream(options: StructuredOutputOptions<WebGllmTextModelOptions>): AsyncIterable<AdapterYieldChunk> {
228
+ await this.load(signalOf(options.chatOptions))
229
+ yield* super.structuredOutputStream(options)
230
+ }
231
+
232
+ override async structuredOutput(options: StructuredOutputOptions<WebGllmTextModelOptions>): Promise<StructuredOutputResult<unknown>> {
233
+ await this.load(signalOf(options.chatOptions))
234
+ return super.structuredOutput(options)
235
+ }
236
+
237
+ /** The request, with `modelOptions` as given, the thinking budget and the prompt cache. */
238
+ /**
239
+ * The engine's usage, as TanStack converts it, but with a cached count of 0
240
+ * kept, which TanStack's conversion leaves out: 0 means the whole prompt was
241
+ * read, where no count means the engine didn't say.
242
+ */
243
+ protected override chatCompletionsTokenUsage(
244
+ usage: { prompt_tokens_details?: { cached_tokens?: number } | null } | null | undefined,
245
+ source?: unknown
246
+ ): TokenUsage | undefined {
247
+ const result = super.chatCompletionsTokenUsage(usage as never, source)
248
+ const cachedTokens = usage?.prompt_tokens_details?.cached_tokens
249
+ if (!result || cachedTokens !== 0) return result
250
+ return { ...result, promptTokensDetails: { ...result.promptTokensDetails, cachedTokens } }
251
+ }
252
+
253
+ protected override mapOptionsToRequest(options: TextOptions): CreateParams & { stream: true } {
254
+ const request = super.mapOptionsToRequest(options)
255
+ const resolved = resolveReasoning(options.reasoning, this.modelReasoning(this.model))
256
+ const budget = resolved?.value === "high" ? resolved.budgetTokens : undefined
257
+ return Object.assign(request, {
258
+ ...(budget !== undefined && { reasoning: { max_tokens: budget } }),
259
+ ...(options.promptCache?.retention === "none" && { cache_prompt: false })
260
+ })
261
+ }
262
+
263
+ protected override modelReasoning(model: string) {
264
+ return (listed(model) ? modelMeta(model).reasoning : UNLISTED_REASONING) as ModelReasoning | undefined
265
+ }
266
+
267
+ /** webgllm streams thinking as `reasoning_content`. */
268
+ protected override extractReasoning(chunk: unknown) {
269
+ const text = (chunk as { choices?: Array<{ delta?: { reasoning_content?: string } }> }).choices?.[0]?.delta
270
+ ?.reasoning_content
271
+ return text ? { text } : undefined
272
+ }
273
+
274
+ /** Schemas as given: webgllm constrains to any JSON Schema, without OpenAI's strict-mode rewrite. */
275
+ protected override makeStructuredOutputCompatible(schema: Record<string, any>) {
276
+ return schema
277
+ }
278
+ }
279
+
280
+ /**
281
+ * A TanStack AI text adapter for a webgllm model on WebGL 2, for browsers
282
+ * without WebGPU (Firefox on Android, for one).
283
+ *
284
+ * @example
285
+ * ```ts
286
+ * chat({ adapter: webgllmText("LiquidAI/LFM3-1.7B-GGUF"), messages, threadId })
287
+ * ```
288
+ */
289
+ export function webgllmText<TModel extends WebGllmModel>(model: TModel, config?: WebGllmConfig): WebGllmTextAdapter<TModel> {
290
+ return new WebGllmTextAdapter(config ?? {}, model)
291
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The models webgllm runs: webgllm's own catalog, re-exported under this
3
+ * adapter's names, with their reasoning levels in TanStack AI's shape (for
4
+ * `chat({ reasoning })` and `supportedReasoningLevels()`).
5
+ */
6
+ import type { ModelReasoning } from "@tanstack/ai"
7
+ import type { ModelReasoningCapability } from "@tanstack/ai/adapter-internals"
8
+ import { WEBGL_MODEL_META, WEBGL_TEXT_MODELS } from "webgllm"
9
+ import type { WebGlModelMeta, WebGlModelReasoning, WebGlTextModel } from "webgllm"
10
+
11
+ // webgllm's catalog as it is: the models it runs, and each one's entry by name.
12
+ export { WEBGL_TEXT_MODELS as WEBGLLM_CHAT_MODELS, WEBGL_MODEL_META as WEBGLLM_MODEL_META } from "webgllm"
13
+ export type { WebGlTextModel as WebGllmChatModel, WebGlModelMeta as WebGllmModelMeta } from "webgllm"
14
+
15
+ type WebGllmChatModel = WebGlTextModel
16
+
17
+ /**
18
+ * The reasoning levels of each model that thinks, exactly as its catalog
19
+ * entry gives them: LFM3 and Qwen3.5 switch between `off` and `high`, the
20
+ * LFM2.5 Thinking builds always think (`high` only). Models that don't think
21
+ * aren't in it.
22
+ */
23
+ export type WebGllmModelReasoningByName = {
24
+ [K in WebGllmChatModel as WebGlModelReasoning<K> extends undefined ? never : K]: ModelReasoningCapability<
25
+ NonNullable<WebGlModelReasoning<K>>
26
+ >
27
+ }
28
+
29
+ /** Reasoning data of each model (`false`: it doesn't think), for `supportedReasoningLevels()`. */
30
+ export const WEBGLLM_MODEL_REASONING = Object.fromEntries(
31
+ WEBGL_TEXT_MODELS.map((model) => [model, WEBGL_MODEL_META[model].reasoning ?? false])
32
+ ) as Readonly<Record<WebGllmChatModel, ModelReasoning>>
33
+
34
+ /** A model's catalog entry. */
35
+ export const modelMeta = (model: WebGllmChatModel): WebGlModelMeta => WEBGL_MODEL_META[model]
@@ -0,0 +1,236 @@
1
+ /**
2
+ * A stand-in for the shared WebGLLM instance's methods, so the adapter's
3
+ * tests run without workers or models: loads resolve at once (after the
4
+ * scripted download progress), and each completion streams the chunks the
5
+ * test scripts. (No tests of its own.)
6
+ */
7
+ import type {
8
+ ChatCompletionChunk,
9
+ ChatCompletionChunkDelta,
10
+ ChatCompletionCreateParamsStreaming,
11
+ CompletionTimings,
12
+ FinishReason,
13
+ LoadModelOptions,
14
+ LoadProgress,
15
+ RequestOptions,
16
+ WebGLLMEventMap
17
+ } from "webgllm"
18
+ import { resolveDebugOption } from "@tanstack/ai/adapter-internals"
19
+ import type { TextOptions } from "@tanstack/ai"
20
+
21
+ import { webgllm } from "./adapter.js"
22
+ import type { WebGllmTextModelOptions } from "./adapter.js"
23
+
24
+ export const MODEL = "LiquidAI/LFM3-1.7B-GGUF"
25
+
26
+ /** What a scripted completion streams: chunks, an Error to throw there, or a pause. */
27
+ export type Step = ChatCompletionChunk | Error | { wait: Promise<void> }
28
+
29
+ export const fake = {
30
+ requests: [] as Array<{ body: ChatCompletionCreateParamsStreaming; options: RequestOptions }>,
31
+ loads: [] as Array<{ model: string; options: LoadModelOptions }>,
32
+ unloads: [] as Array<string | undefined>,
33
+ /** Models the fake holds loaded, with the context each was loaded for. */
34
+ loaded: new Map<string, number | undefined>(),
35
+ /** Models whose files the fake's storage holds (models.downloaded). */
36
+ stored: new Set<string>(),
37
+ /** Download progress each load reports before it finishes. */
38
+ progress: [] as Array<LoadProgress>,
39
+ /** Rejects the next loads with it. */
40
+ loadError: undefined as Error | undefined,
41
+ /** Holds loads until it resolves. */
42
+ loadGate: undefined as Promise<void> | undefined,
43
+ /** The steps of each completion, from its request. */
44
+ script: (() => [chunk({ role: "assistant", content: "Hello" }), chunk({}, "stop"), usageChunk()]) as (
45
+ body: ChatCompletionCreateParamsStreaming
46
+ ) => Array<Step>,
47
+ reset() {
48
+ this.requests = []
49
+ this.loads = []
50
+ this.unloads = []
51
+ this.loaded.clear()
52
+ this.stored.clear()
53
+ this.progress = []
54
+ this.loadError = undefined
55
+ this.loadGate = undefined
56
+ this.script = () => [chunk({ role: "assistant", content: "Hello" }), chunk({}, "stop"), usageChunk()]
57
+ }
58
+ }
59
+
60
+ export function chunk(delta: ChatCompletionChunkDelta, finish: FinishReason | null = null): ChatCompletionChunk {
61
+ return {
62
+ id: "chatcmpl-test",
63
+ object: "chat.completion.chunk",
64
+ created: 0,
65
+ model: MODEL,
66
+ choices: [{ index: 0, delta, finish_reason: finish, logprobs: null }]
67
+ }
68
+ }
69
+
70
+ export const TIMINGS: CompletionTimings = {
71
+ first_token_ms: 12,
72
+ prompt_ms: 10,
73
+ decode_ms: 40,
74
+ prompt_tokens_per_second: 900,
75
+ decode_tokens_per_second: 25
76
+ }
77
+
78
+ export function usageChunk(
79
+ usage: { prompt?: number; completion?: number; cached?: number; reasoning?: number } = {}
80
+ ): ChatCompletionChunk {
81
+ const prompt = usage.prompt ?? 9
82
+ const completion = usage.completion ?? 2
83
+ return {
84
+ id: "chatcmpl-test",
85
+ object: "chat.completion.chunk",
86
+ created: 0,
87
+ model: MODEL,
88
+ choices: [],
89
+ usage: {
90
+ prompt_tokens: prompt,
91
+ completion_tokens: completion,
92
+ total_tokens: prompt + completion,
93
+ prompt_tokens_details: { cached_tokens: usage.cached ?? 0 },
94
+ completion_tokens_details: { reasoning_tokens: usage.reasoning ?? 0 }
95
+ },
96
+ timings: TIMINGS
97
+ }
98
+ }
99
+
100
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
101
+ const abortError = () => new DOMException("The operation was aborted.", "AbortError")
102
+
103
+ function stream(steps: Array<Step>, signal: AbortSignal | undefined): AsyncIterable<ChatCompletionChunk> {
104
+ let i = 0
105
+ let done = false
106
+ const iterator: AsyncIterator<ChatCompletionChunk> = {
107
+ async next() {
108
+ for (;;) {
109
+ await tick()
110
+ if (signal?.aborted) throw abortError()
111
+ if (done || i >= steps.length) return { value: undefined, done: true }
112
+ const step = steps[i++]!
113
+ if (step instanceof Error) throw step
114
+ if ("wait" in step) {
115
+ await step.wait
116
+ continue
117
+ }
118
+ return { value: step, done: false }
119
+ }
120
+ },
121
+ async return() {
122
+ done = true
123
+ return { value: undefined, done: true }
124
+ }
125
+ }
126
+ return { [Symbol.asyncIterator]: () => iterator }
127
+ }
128
+
129
+ /** Dispatches a lifecycle event on the real instance, as webgllm itself does. */
130
+ export function emit<K extends keyof WebGLLMEventMap>(type: K, event: WebGLLMEventMap[K]) {
131
+ ;(webgllm as unknown as { dispatch(type: K, event: WebGLLMEventMap[K]): void }).dispatch(type, event)
132
+ }
133
+
134
+ const abortable = (promise: Promise<void>, signal?: AbortSignal) =>
135
+ signal
136
+ ? Promise.race([
137
+ promise,
138
+ new Promise<never>((_, reject) => {
139
+ if (signal.aborted) reject(signal.reason)
140
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true })
141
+ })
142
+ ])
143
+ : promise
144
+
145
+ // Like webgllm's loadModel: at once when loaded for that context; otherwise
146
+ // the scripted download progress (with webgllm's events), then "load".
147
+ webgllm.loadModel = async (model, options = {}) => {
148
+ options.signal?.throwIfAborted()
149
+ fake.loads.push({ model, options })
150
+ if (fake.loaded.has(model) && fake.loaded.get(model) === options.contextLength) return
151
+ for (const progress of fake.progress) {
152
+ emit("progress", progress)
153
+ options.onProgress?.(progress)
154
+ }
155
+ await abortable(fake.loadGate ?? Promise.resolve(), options.signal)
156
+ if (fake.loadError) {
157
+ emit("error", { model, error: fake.loadError })
158
+ throw fake.loadError
159
+ }
160
+ fake.loaded.set(model, options.contextLength)
161
+ emit("load", { model, contextLength: options.contextLength ?? 32_768 })
162
+ }
163
+ webgllm.unloadModel = async (model) => {
164
+ fake.unloads.push(model)
165
+ for (const name of model === undefined ? [...fake.loaded.keys()] : [model]) {
166
+ if (fake.loaded.delete(name)) emit("unload", { model: name, reason: "unloaded" })
167
+ }
168
+ }
169
+ webgllm.models.downloaded = async (model) => fake.loaded.has(model) || fake.stored.has(model)
170
+ webgllm.chat.completions.create = (async (body: ChatCompletionCreateParamsStreaming, options: RequestOptions = {}) => {
171
+ fake.requests.push({ body, options })
172
+ options.signal?.throwIfAborted()
173
+ if (body.stream !== true) return completion(body, fake.script(body))
174
+ return stream(fake.script(body), options.signal)
175
+ }) as typeof webgllm.chat.completions.create
176
+
177
+ /** A request without `stream`: the scripted chunks as one completion, as webgllm answers it. */
178
+ async function completion(body: { model: string }, steps: Array<Step>) {
179
+ let content = ""
180
+ let reasoning = ""
181
+ let finish: FinishReason | null = null
182
+ let usage: ChatCompletionChunk["usage"]
183
+ for (const step of steps) {
184
+ if (step instanceof Error) throw step
185
+ if ("wait" in step) {
186
+ await step.wait
187
+ continue
188
+ }
189
+ const choice = step.choices[0]
190
+ content += choice?.delta.content ?? ""
191
+ reasoning += choice?.delta.reasoning_content ?? ""
192
+ finish = choice?.finish_reason ?? finish
193
+ usage = step.usage ?? usage
194
+ }
195
+ return {
196
+ id: "chatcmpl-fake",
197
+ object: "chat.completion",
198
+ created: 0,
199
+ model: body.model,
200
+ choices: [
201
+ {
202
+ index: 0,
203
+ message: { role: "assistant", content, ...(reasoning && { reasoning_content: reasoning }) },
204
+ finish_reason: finish ?? "stop"
205
+ }
206
+ ],
207
+ ...(usage && { usage })
208
+ }
209
+ }
210
+
211
+ /** A chatStream call's options. */
212
+ export function textOptions(
213
+ extra: Partial<TextOptions<WebGllmTextModelOptions>> = {}
214
+ ): TextOptions<WebGllmTextModelOptions> {
215
+ return {
216
+ logger: resolveDebugOption(false),
217
+ threadId: "webgllm-test",
218
+ model: MODEL,
219
+ messages: [{ role: "user", content: "hi" }],
220
+ ...extra
221
+ }
222
+ }
223
+
224
+ /** Every event a stream yields. */
225
+ export async function collect<T>(stream: AsyncIterable<T>): Promise<Array<T>> {
226
+ const out: Array<T> = []
227
+ for await (const event of stream) out.push(event)
228
+ return out
229
+ }
230
+
231
+ /** The last request's body. */
232
+ export const lastBody = () => {
233
+ const request = fake.requests.at(-1)
234
+ if (!request) throw new Error("No request was sent.")
235
+ return request.body
236
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * TanStack AI adapter for webgllm: LFM3, LFM2.5 and Qwen3.5 in the browser
3
+ * on WebGL 2, for browsers without WebGPU (Firefox on Android, for one).
4
+ * The model catalog (model-meta.ts) is exported here too.
5
+ */
6
+ export { webgllmText, WebGllmTextAdapter } from "./adapter.js"
7
+ export type { Availability, DownloadProgressCallback, WebGllmConfig, WebGllmModel, WebGllmTextModelOptions } from "./adapter.js"
8
+ // webgllm's own worker handler, for a worker of your own (`worker` in the adapter's config).
9
+ export { WebGLLMWorkerHandler } from "webgllm"
10
+ export { WEBGLLM_CHAT_MODELS, WEBGLLM_MODEL_META, WEBGLLM_MODEL_REASONING } from "./catalog.js"
11
+ export type { WebGllmChatModel, WebGllmModelMeta, WebGllmModelReasoningByName } from "./catalog.js"
@@ -0,0 +1,10 @@
1
+ /** Where the engine can't run (Node has no WebGL 2), every model is "unavailable". */
2
+ import assert from "node:assert/strict"
3
+ import { test } from "node:test"
4
+
5
+ import { webgllmText } from "./adapter.js"
6
+ import { MODEL } from "./fake-webgllm.test.js"
7
+
8
+ test("availability is unavailable where the engine can't run", async () => {
9
+ assert.equal(await webgllmText(MODEL).availability(), "unavailable")
10
+ })