@tanstack-ai-browser/webgllm 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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * What the adapter adds to TanStack's Chat Completions adapter: loading the
3
+ * model, webgllm's option names, reasoning levels and thinking budget, the
4
+ * history it reads, structured output's schema, and aborts.
5
+ */
6
+ import assert from "node:assert/strict"
7
+ import { beforeEach, test } from "node:test"
8
+ import { WebGLLM, WebGLLMError } from "webgllm"
9
+
10
+ import { webgllmText } from "./adapter.js"
11
+ import { chunk, collect, fake, lastBody, MODEL, textOptions, usageChunk } from "./fake-webgllm.test.js"
12
+
13
+ ;(WebGLLM as unknown as { isSupported: () => boolean }).isSupported = () => true
14
+ beforeEach(() => fake.reset())
15
+
16
+ type Event = { type: string } & Record<string, unknown>
17
+ const types = (events: Array<unknown>) => (events as Array<Event>).map((e) => e.type)
18
+
19
+ test("a call loads the model, then streams thinking, text and tool calls as AG-UI events", async () => {
20
+ fake.script = () => [
21
+ chunk({ role: "assistant", reasoning_content: "Let me look." }),
22
+ chunk({ content: "Checking." }),
23
+ chunk({ tool_calls: [{ index: 0, id: "call_a", type: "function", function: { name: "lookup", arguments: '{"q":"a"}' } }] }),
24
+ chunk({}, "tool_calls"),
25
+ usageChunk({ prompt: 20, completion: 7 })
26
+ ]
27
+ const lookup = { name: "lookup", description: "Look up", inputSchema: { type: "object", properties: { q: { type: "string" } } } }
28
+ const events = (await collect(webgllmText(MODEL, { contextLength: 4096 }).chatStream(textOptions({ tools: [lookup] })))) as Array<Event>
29
+ assert.equal(fake.loads[0]?.model, MODEL)
30
+ assert.equal(fake.loads[0]?.options.contextLength, 4096)
31
+ const kinds = types(events)
32
+ for (const kind of ["RUN_STARTED", "REASONING_MESSAGE_CONTENT", "TEXT_MESSAGE_CONTENT", "TOOL_CALL_START", "TOOL_CALL_END", "RUN_FINISHED"]) {
33
+ assert.ok(kinds.includes(kind), `${kind} in ${kinds.join(", ")}`)
34
+ }
35
+ assert.equal(events.find((e) => e.type === "REASONING_MESSAGE_CONTENT")?.delta, "Let me look.")
36
+ assert.equal(events.find((e) => e.type === "TOOL_CALL_END")?.toolName ?? events.find((e) => e.type === "TOOL_CALL_END")?.toolCallName, "lookup")
37
+ assert.equal(events.at(-1)?.finishReason, "tool_calls")
38
+ const body = lastBody() as unknown as Record<string, unknown>
39
+ assert.equal(body.model, MODEL)
40
+ assert.equal(body.stream, true)
41
+ // The schema as given, without OpenAI's strict-mode rewrite (TanStack only closes it to undeclared fields).
42
+ assert.deepEqual((body.tools as Array<{ function: { parameters: unknown } }>)[0]?.function.parameters, {
43
+ ...lookup.inputSchema,
44
+ additionalProperties: false
45
+ })
46
+ })
47
+
48
+ test("modelOptions go to webgllm as given", async () => {
49
+ const given = {
50
+ max_completion_tokens: 64,
51
+ temperature: 0.2,
52
+ top_p: 0.9,
53
+ top_k: 40,
54
+ min_p: 0.05,
55
+ repetition_penalty: 1.1,
56
+ presence_penalty: 0.5,
57
+ stop: ["END"],
58
+ seed: 7,
59
+ parallel_tool_calls: false,
60
+ repair_tool_calls: false
61
+ }
62
+ await collect(webgllmText(MODEL).chatStream(textOptions({ modelOptions: given })))
63
+ const body = lastBody() as unknown as Record<string, unknown>
64
+ for (const [key, value] of Object.entries(given)) assert.deepEqual(body[key], value, key)
65
+ })
66
+
67
+ test("reasoning: off and high as webgllm's reasoning_effort, a budget as reasoning.max_tokens", async () => {
68
+ await collect(webgllmText(MODEL).chatStream(textOptions({ reasoning: { level: "off", summary: false } })))
69
+ assert.equal((lastBody() as unknown as Record<string, unknown>).reasoning_effort, "none")
70
+ await collect(webgllmText(MODEL).chatStream(textOptions({ reasoning: { level: "high", summary: false, budgetTokens: 256 } })))
71
+ const body = lastBody() as unknown as Record<string, unknown>
72
+ assert.equal(body.reasoning_effort, "high")
73
+ assert.deepEqual(body.reasoning, { max_tokens: 256 })
74
+ // Off ignores a budget; a model that doesn't think gets neither.
75
+ await collect(webgllmText(MODEL).chatStream(textOptions({ reasoning: { level: "off", summary: false } })))
76
+ assert.equal((lastBody() as unknown as Record<string, unknown>).reasoning, undefined)
77
+ await collect(webgllmText("LiquidAI/LFM2.5-350M-GGUF").chatStream(textOptions({ model: "LiquidAI/LFM2.5-350M-GGUF" })))
78
+ assert.equal((lastBody() as unknown as Record<string, unknown>).reasoning_effort, undefined)
79
+ })
80
+
81
+ test("no prompt cache when asked", async () => {
82
+ await collect(webgllmText(MODEL).chatStream(textOptions({ promptCache: { retention: "none" } } as never)))
83
+ assert.equal((lastBody() as unknown as { cache_prompt?: boolean }).cache_prompt, false)
84
+ })
85
+
86
+ test("structured output: the schema as given in response_format, the system prompt as given, and the answer parsed", async () => {
87
+ const schema = { type: "object", properties: { moveId: { type: "string" }, note: { type: "string" } }, required: ["moveId"] }
88
+ fake.script = () => [chunk({ content: '{"moveId":"b6-a5"}' }), chunk({}, "stop"), usageChunk()]
89
+ const result = await webgllmText(MODEL).structuredOutput({ chatOptions: textOptions({ systemPrompts: ["Play well."] }), outputSchema: schema })
90
+ assert.deepEqual(result.data, { moveId: "b6-a5" })
91
+ const body = lastBody() as unknown as {
92
+ messages: Array<{ role: string; content: string }>
93
+ response_format: { json_schema: { schema: unknown } }
94
+ }
95
+ assert.deepEqual(body.response_format.json_schema.schema, schema)
96
+ assert.equal(body.messages[0]!.content, "Play well.")
97
+
98
+ const events = (await collect(
99
+ webgllmText(MODEL).structuredOutputStream({ chatOptions: textOptions(), outputSchema: schema })
100
+ )) as Array<Event>
101
+ assert.ok(types(events).includes("RUN_FINISHED"))
102
+ })
103
+
104
+ test("a model that can't load rejects with webgllm's error, before any request", async () => {
105
+ fake.loadError = new WebGLLMError("storage-full", "No room for the model.")
106
+ await assert.rejects(collect(webgllmText(MODEL).chatStream(textOptions())), (error) => error instanceof WebGLLMError && error.code === "storage-full")
107
+ assert.equal(fake.requests.length, 0)
108
+ })
109
+
110
+ test("the caller's signal reaches webgllm, which stops the generation", async () => {
111
+ const controller = new AbortController()
112
+ await collect(webgllmText(MODEL).chatStream(textOptions({ request: { signal: controller.signal } })))
113
+ assert.equal(fake.requests[0]?.options.signal, controller.signal)
114
+ })
115
+
116
+ test("images: a model with a vision encoder reads them, sent as OpenAI image_url parts; a text model doesn't", async () => {
117
+ const qwen = "onnx-community/Qwen3.5-2B-ONNX"
118
+ assert.deepEqual(webgllmText(qwen).inputModalities, ["text", "image"])
119
+ // webgllm 0.3.3: Liquid's vision models, in GGUF and ONNX, and Qwen3.5's GGUF with its projector.
120
+ for (const vision of ["LiquidAI/LFM3-VL-2B-GGUF", "LiquidAI/LFM2.5-VL-450M-ONNX", "unsloth/Qwen3.5-2B-GGUF"] as const) {
121
+ assert.deepEqual(webgllmText(vision).inputModalities, ["text", "image"], vision)
122
+ }
123
+ assert.deepEqual(webgllmText("LiquidAI/LFM3-1.7B-GGUF").inputModalities, ["text"])
124
+ assert.equal(webgllmText("someone/Unlisted-ONNX").inputModalities, undefined)
125
+ const image = { type: "image", source: { type: "data", value: "AAAA", mimeType: "image/png" } }
126
+ await collect(
127
+ webgllmText(qwen).chatStream(
128
+ textOptions({ model: qwen, messages: [{ role: "user", content: [image, { type: "text", content: "What's this?" }] }] } as never)
129
+ )
130
+ )
131
+ const body = lastBody() as unknown as { messages: Array<{ content: unknown }> }
132
+ assert.deepEqual(body.messages.at(-1)!.content, [
133
+ { type: "image_url", image_url: { url: "data:image/png;base64,AAAA", detail: "auto" } },
134
+ { type: "text", text: "What's this?" }
135
+ ])
136
+ })