@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/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/adapter.d.ts +150 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +173 -0
- package/dist/catalog.d.ts +25 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +7 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -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/models.d.ts +93 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +274 -0
- package/package.json +65 -4
- package/src/adapter.test.ts +100 -0
- package/src/adapter.ts +291 -0
- package/src/catalog.ts +35 -0
- package/src/fake-webgllm.test.ts +236 -0
- package/src/index.ts +11 -0
- package/src/unavailable.test.ts +10 -0
- package/src/wrapper.test.ts +146 -0
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
|
+
})
|