@happyvertical/smrt-svelte 0.51.11 → 0.51.13

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 (27) hide show
  1. package/AGENTS.md +27 -0
  2. package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts +129 -0
  3. package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts.map +1 -0
  4. package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.js +331 -0
  5. package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.test.js +464 -0
  6. package/dist/browser-ai/adapters/llm/index.d.ts +2 -0
  7. package/dist/browser-ai/adapters/llm/index.d.ts.map +1 -1
  8. package/dist/browser-ai/adapters/llm/index.js +2 -0
  9. package/dist/browser-ai/adapters/llm/types.d.ts +18 -0
  10. package/dist/browser-ai/adapters/llm/types.d.ts.map +1 -1
  11. package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts +67 -0
  12. package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts.map +1 -0
  13. package/dist/browser-ai/adapters/llm/webllm-inference-backend.js +364 -0
  14. package/dist/browser-ai/adapters/llm/webllm-inference-backend.test.js +646 -0
  15. package/dist/browser-ai/adapters/llm/webllm.d.ts.map +1 -1
  16. package/dist/browser-ai/adapters/llm/webllm.js +19 -2
  17. package/dist/browser-ai/svelte/components/ModelStatusControl.svelte +190 -0
  18. package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts +17 -0
  19. package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts.map +1 -0
  20. package/dist/browser-ai/svelte/components/__tests__/ModelStatusControl.test.js +172 -0
  21. package/dist/browser-ai/svelte/index.d.ts +2 -1
  22. package/dist/browser-ai/svelte/index.d.ts.map +1 -1
  23. package/dist/browser-ai/svelte/index.js +3 -1
  24. package/dist/i18n/strings.browser-ai.d.ts +4 -0
  25. package/dist/i18n/strings.browser-ai.d.ts.map +1 -0
  26. package/dist/i18n/strings.browser-ai.js +4 -0
  27. package/package.json +19 -15
@@ -0,0 +1,646 @@
1
+ /**
2
+ * The local (`webllm`) inference backend.
3
+ *
4
+ * Two invariants this suite exists to pin:
5
+ *
6
+ * 1. **An unusable local backend reports `unavailable`, not `idle`.** The path
7
+ * only auto-selects `ready`, so a device without WebGPU must not sit in a
8
+ * state a caller might promote — and `load()` on one must fail rather than
9
+ * start a download that can never work.
10
+ * 2. **A turn the adapter cannot represent is refused, not degraded.** The
11
+ * adapter is a text-only conversation contract; silently rewriting a `tool`
12
+ * observation or dropping `options.tools` would let a model answer as
13
+ * though it had seen context (or tools) it never received.
14
+ */
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+ const getLLMMock = vi.hoisted(() => vi.fn());
17
+ vi.mock('./factory.js', () => ({ getLLM: getLLMMock }));
18
+ import { createWebLlmInferenceBackend } from './webllm-inference-backend.js';
19
+ const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
20
+ /** jsdom has no `navigator.gpu`; detectCapabilities keys off `'gpu' in navigator`. */
21
+ function withWebGpu() {
22
+ Object.defineProperty(globalThis, 'navigator', {
23
+ value: { ...globalThis.navigator, gpu: {} },
24
+ configurable: true,
25
+ });
26
+ }
27
+ function makeAdapter() {
28
+ let initState = 'uninitialized';
29
+ let failure = null;
30
+ const calls = {
31
+ ensure: [],
32
+ chat: [],
33
+ stream: [],
34
+ unload: 0,
35
+ };
36
+ return {
37
+ type: 'webllm',
38
+ get initState() {
39
+ return initState;
40
+ },
41
+ currentModel: null,
42
+ calls,
43
+ setInitState(state) {
44
+ initState = state;
45
+ },
46
+ fail(error) {
47
+ failure = error;
48
+ },
49
+ async ensureInitialized(modelId, onProgress) {
50
+ calls.ensure.push(modelId);
51
+ initState = 'initializing';
52
+ onProgress?.({
53
+ state: 'downloading',
54
+ bytesLoaded: 10,
55
+ bytesTotal: 40,
56
+ percent: 25,
57
+ });
58
+ initState = 'ready';
59
+ },
60
+ async chat(messages, options) {
61
+ calls.chat.push({ messages, options });
62
+ if (failure)
63
+ throw failure;
64
+ return {
65
+ content: 'reply',
66
+ usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 },
67
+ model: 'm',
68
+ finishReason: 'stop',
69
+ };
70
+ },
71
+ async *stream(messages, options) {
72
+ calls.stream.push({ messages, options });
73
+ options?.onToken?.('a');
74
+ yield 'a';
75
+ },
76
+ async unloadModel() {
77
+ calls.unload += 1;
78
+ initState = 'uninitialized';
79
+ },
80
+ async dispose() { },
81
+ };
82
+ }
83
+ function progressOf(backend) {
84
+ return backend.progress;
85
+ }
86
+ async function expectPathError(run, code) {
87
+ const error = await run().then(() => undefined, (thrown) => thrown);
88
+ expect(error).toMatchObject({ name: 'InferencePathError', code });
89
+ }
90
+ describe('createWebLlmInferenceBackend', () => {
91
+ beforeEach(() => {
92
+ // Default jsdom navigator: no `gpu`, so the backend is unavailable.
93
+ Object.defineProperty(globalThis, 'navigator', {
94
+ value: { ...globalThis.navigator },
95
+ configurable: true,
96
+ });
97
+ });
98
+ afterEach(() => {
99
+ if (originalNavigator) {
100
+ Object.defineProperty(globalThis, 'navigator', originalNavigator);
101
+ }
102
+ vi.unstubAllGlobals();
103
+ });
104
+ it('reports unavailable without WebGPU, so auto-selection skips it', () => {
105
+ const backend = createWebLlmInferenceBackend({ adapter: makeAdapter() });
106
+ expect(backend.status).toBe('unavailable');
107
+ expect(backend.kind).toBe('local');
108
+ });
109
+ it('defaults its id to "local" and accepts an override', () => {
110
+ expect(createWebLlmInferenceBackend().id).toBe('local');
111
+ expect(createWebLlmInferenceBackend({ id: 'on-device' }).id).toBe('on-device');
112
+ });
113
+ it('reports idle before a load, ready after, once WebGPU exists', async () => {
114
+ withWebGpu();
115
+ const adapter = makeAdapter();
116
+ const backend = createWebLlmInferenceBackend({ adapter });
117
+ expect(backend.status).toBe('idle');
118
+ await backend.load();
119
+ expect(backend.status).toBe('ready');
120
+ });
121
+ it('refuses to load without WebGPU rather than starting a doomed download', async () => {
122
+ const adapter = makeAdapter();
123
+ const backend = createWebLlmInferenceBackend({ adapter });
124
+ await expectPathError(() => backend.load(), 'no_usable_backend');
125
+ expect(adapter.calls.ensure).toEqual([]);
126
+ });
127
+ it('exposes adapter progress during a load and clears it after', async () => {
128
+ withWebGpu();
129
+ const adapter = makeAdapter();
130
+ const backend = createWebLlmInferenceBackend({ adapter });
131
+ let during;
132
+ const load = backend.load(() => {
133
+ during = progressOf(backend);
134
+ });
135
+ await load;
136
+ expect(during).toMatchObject({ percent: 25, bytesLoaded: 10 });
137
+ expect(progressOf(backend)).toBeUndefined();
138
+ });
139
+ it('forwards the requested model to the adapter', async () => {
140
+ withWebGpu();
141
+ const adapter = makeAdapter();
142
+ const backend = createWebLlmInferenceBackend({
143
+ adapter,
144
+ model: 'SmolLM2-360M-Instruct-q4f16_1-MLC',
145
+ });
146
+ await backend.load();
147
+ expect(adapter.calls.ensure).toEqual(['SmolLM2-360M-Instruct-q4f16_1-MLC']);
148
+ });
149
+ it('reports loading while a load is in flight', async () => {
150
+ withWebGpu();
151
+ const adapter = makeAdapter();
152
+ // The gate is built before `load()` so the resolver exists synchronously —
153
+ // `ensureAdapter()` is async, so the adapter's own promise is created a
154
+ // tick later and a deferred resolver assigned inside it would not exist
155
+ // yet.
156
+ let release = () => { };
157
+ const gate = new Promise((resolve) => {
158
+ release = resolve;
159
+ });
160
+ adapter.ensureInitialized = async () => {
161
+ await gate;
162
+ adapter.setInitState('ready');
163
+ };
164
+ const backend = createWebLlmInferenceBackend({ adapter });
165
+ const load = backend.load();
166
+ // The adapter has not flipped `initState` yet; status must still say so.
167
+ expect(backend.status).toBe('loading');
168
+ release();
169
+ await load;
170
+ expect(backend.status).toBe('ready');
171
+ });
172
+ it('reports error status when the adapter failed', () => {
173
+ withWebGpu();
174
+ const adapter = makeAdapter();
175
+ adapter.setInitState('error');
176
+ expect(createWebLlmInferenceBackend({ adapter }).status).toBe('error');
177
+ });
178
+ it('keeps a failed load visible as an error, not as idle', async () => {
179
+ withWebGpu();
180
+ const adapter = makeAdapter();
181
+ adapter.ensureInitialized = async () => {
182
+ // The real adapter records the failure in `initState` before rethrowing.
183
+ adapter.setInitState('error');
184
+ throw new Error('no WebGPU device');
185
+ };
186
+ const backend = createWebLlmInferenceBackend({ adapter });
187
+ await expect(backend.load()).rejects.toThrow('no WebGPU device');
188
+ // `idle` would read as "never loaded", so a control would offer Load rather
189
+ // than Retry for a load that just failed, and the failure would be visible
190
+ // only to the one caller holding the rejected promise.
191
+ expect(backend.status).toBe('error');
192
+ expect(progressOf(backend)).toMatchObject({ state: 'error' });
193
+ });
194
+ it('refuses tool definitions on both chat and stream', async () => {
195
+ withWebGpu();
196
+ const adapter = makeAdapter();
197
+ const backend = createWebLlmInferenceBackend({ adapter });
198
+ const messages = [{ role: 'user', content: 'hi' }];
199
+ const tools = [
200
+ { type: 'function', function: { name: 'product_list' } },
201
+ ];
202
+ await expectPathError(() => backend.chat([...messages], { tools: [...tools] }), 'unsupported_message');
203
+ await expectPathError(async () => {
204
+ for await (const _chunk of backend.stream([...messages], {
205
+ tools: [...tools],
206
+ })) {
207
+ /* drain */
208
+ }
209
+ }, 'unsupported_message');
210
+ // Neither refusal may reach the adapter.
211
+ expect(adapter.calls.chat).toEqual([]);
212
+ expect(adapter.calls.stream).toEqual([]);
213
+ });
214
+ it('refuses a tool-role message rather than rewriting it', async () => {
215
+ withWebGpu();
216
+ const adapter = makeAdapter();
217
+ const backend = createWebLlmInferenceBackend({ adapter });
218
+ await expectPathError(() => backend.chat([
219
+ { role: 'user', content: 'hi' },
220
+ { role: 'tool', content: '{"rows":[]}' },
221
+ ]), 'unsupported_message');
222
+ expect(adapter.calls.chat).toEqual([]);
223
+ });
224
+ it('refuses a message carrying tool_calls rather than dropping the record', async () => {
225
+ withWebGpu();
226
+ const adapter = makeAdapter();
227
+ const backend = createWebLlmInferenceBackend({ adapter });
228
+ // The old mapper inspected only `role` and `content`, so this shape was
229
+ // accepted and its tool calls silently discarded — the model would then
230
+ // answer as though the call had never happened.
231
+ await expectPathError(() => backend.chat([
232
+ {
233
+ role: 'assistant',
234
+ content: '',
235
+ tool_calls: [
236
+ {
237
+ id: 'c1',
238
+ type: 'function',
239
+ function: { name: 'product_list', arguments: '{}' },
240
+ },
241
+ ],
242
+ },
243
+ ]), 'unsupported_message');
244
+ expect(adapter.calls.chat).toEqual([]);
245
+ });
246
+ it('refuses a toolChoice even with no tools listed', async () => {
247
+ withWebGpu();
248
+ const adapter = makeAdapter();
249
+ const backend = createWebLlmInferenceBackend({ adapter });
250
+ await expectPathError(() => backend.chat([{ role: 'user', content: 'hi' }], {
251
+ toolChoice: 'none',
252
+ }), 'unsupported_message');
253
+ await expectPathError(async () => {
254
+ for await (const _chunk of backend.stream([{ role: 'user', content: 'hi' }], { toolChoice: 'auto' })) {
255
+ /* drain */
256
+ }
257
+ }, 'unsupported_message');
258
+ expect(adapter.calls.chat).toEqual([]);
259
+ expect(adapter.calls.stream).toEqual([]);
260
+ });
261
+ it('refuses multimodal content rather than dropping it', async () => {
262
+ withWebGpu();
263
+ const backend = createWebLlmInferenceBackend({ adapter: makeAdapter() });
264
+ await expectPathError(() => backend.chat([
265
+ {
266
+ role: 'user',
267
+ content: [{ type: 'text', text: 'look' }],
268
+ },
269
+ ]), 'unsupported_message');
270
+ });
271
+ it('passes text messages through unchanged and maps the response', async () => {
272
+ withWebGpu();
273
+ const adapter = makeAdapter();
274
+ const backend = createWebLlmInferenceBackend({ adapter });
275
+ const response = await backend.chat([{ role: 'user', content: 'hi' }]);
276
+ expect(adapter.calls.chat[0].messages).toEqual([
277
+ { role: 'user', content: 'hi' },
278
+ ]);
279
+ expect(response).toEqual({
280
+ content: 'reply',
281
+ usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 },
282
+ model: 'm',
283
+ finishReason: 'stop',
284
+ });
285
+ });
286
+ it('omits a finish reason the mirrored response has no member for', async () => {
287
+ withWebGpu();
288
+ const adapter = makeAdapter();
289
+ adapter.chat = async () => ({
290
+ content: 'partial',
291
+ finishReason: 'error',
292
+ });
293
+ const backend = createWebLlmInferenceBackend({ adapter });
294
+ // `AIResponse.finishReason` has no `'error'` member; a completed response
295
+ // simply carries none rather than inventing a value.
296
+ await expect(backend.chat([{ role: 'user', content: 'hi' }])).resolves.toEqual({ content: 'partial' });
297
+ });
298
+ it('routes onProgress to the adapter token sink and forwards options', async () => {
299
+ withWebGpu();
300
+ const adapter = makeAdapter();
301
+ const backend = createWebLlmInferenceBackend({ adapter });
302
+ const onProgress = vi.fn();
303
+ const chunks = [];
304
+ for await (const chunk of backend.stream([{ role: 'user', content: 'hi' }], {
305
+ temperature: 0.4,
306
+ maxTokens: 12,
307
+ topP: 0.9,
308
+ stop: 'END',
309
+ onProgress,
310
+ })) {
311
+ chunks.push(chunk);
312
+ }
313
+ expect(chunks).toEqual(['a']);
314
+ expect(onProgress).toHaveBeenCalledWith('a');
315
+ expect(adapter.calls.stream[0].options).toMatchObject({
316
+ temperature: 0.4,
317
+ maxTokens: 12,
318
+ topP: 0.9,
319
+ // The adapter takes `string[]`; a scalar caller value is normalized.
320
+ stop: ['END'],
321
+ onToken: onProgress,
322
+ });
323
+ });
324
+ it('clears progress and returns to idle on unload', async () => {
325
+ withWebGpu();
326
+ const adapter = makeAdapter();
327
+ const backend = createWebLlmInferenceBackend({ adapter });
328
+ await backend.load();
329
+ await backend.unload();
330
+ expect(adapter.calls.unload).toBe(1);
331
+ expect(progressOf(backend)).toBeUndefined();
332
+ expect(backend.status).toBe('idle');
333
+ });
334
+ it('shares one load between concurrent callers', async () => {
335
+ withWebGpu();
336
+ const adapter = makeAdapter();
337
+ const backend = createWebLlmInferenceBackend({ adapter });
338
+ // Two concurrent callers must join one attempt. A second attempt would
339
+ // reach the adapter's own `initializing` poll, which settles only on
340
+ // `ready` or `error` — so a release landing in between strands it.
341
+ await Promise.all([backend.load(), backend.load(), backend.load()]);
342
+ expect(adapter.calls.ensure.length).toBe(1);
343
+ expect(backend.status).toBe('ready');
344
+ });
345
+ it('is a no-op when the adapter is already ready', async () => {
346
+ withWebGpu();
347
+ const adapter = makeAdapter();
348
+ const backend = createWebLlmInferenceBackend({ adapter });
349
+ await backend.load();
350
+ const ensures = adapter.calls.ensure.length;
351
+ const again = backend.load();
352
+ // Read in the same tick: a second load must not toggle the backend through
353
+ // `loading`, the state auto-selection is told to skip.
354
+ expect(backend.status).toBe('ready');
355
+ await again;
356
+ expect(adapter.calls.ensure.length).toBe(ensures);
357
+ expect(backend.status).toBe('ready');
358
+ });
359
+ it('releases a load that overlaps an unload', async () => {
360
+ withWebGpu();
361
+ const adapter = makeAdapter();
362
+ const backend = createWebLlmInferenceBackend({ adapter });
363
+ const pending = backend.load();
364
+ await backend.unload();
365
+ await pending;
366
+ // The model finished loading after the caller released it, so it must be
367
+ // released again rather than reporting `ready` on a released backend.
368
+ expect(adapter.calls.unload).toBe(2);
369
+ expect(backend.status).toBe('idle');
370
+ expect(progressOf(backend)).toBeUndefined();
371
+ });
372
+ it('keeps the model a later load published when an earlier one is superseded', async () => {
373
+ withWebGpu();
374
+ const adapter = makeAdapter();
375
+ const inner = adapter.ensureInitialized.bind(adapter);
376
+ let releaseFirst = () => { };
377
+ const firstGate = new Promise((resolve) => {
378
+ releaseFirst = resolve;
379
+ });
380
+ let calls = 0;
381
+ adapter.ensureInitialized = async (modelId, onProgress) => {
382
+ calls += 1;
383
+ // Hold the FIRST attempt open so the second finishes ahead of it. Both
384
+ // attempts share one adapter, so that is the ordering in which a stale
385
+ // attempt's cleanup would destroy the model the newer attempt published.
386
+ if (calls === 1)
387
+ await firstGate;
388
+ return inner(modelId, onProgress);
389
+ };
390
+ const backend = createWebLlmInferenceBackend({ adapter });
391
+ const discarded = backend.load();
392
+ await backend.unload();
393
+ const second = backend.load();
394
+ await second;
395
+ releaseFirst();
396
+ await discarded;
397
+ expect(backend.status).toBe('ready');
398
+ });
399
+ it('releases a superseded model when the newer load then fails', async () => {
400
+ withWebGpu();
401
+ const adapter = makeAdapter();
402
+ const inner = adapter.ensureInitialized.bind(adapter);
403
+ let releaseFirst = () => { };
404
+ const firstGate = new Promise((resolve) => {
405
+ releaseFirst = resolve;
406
+ });
407
+ let failSecond = () => { };
408
+ const secondGate = new Promise((_resolve, reject) => {
409
+ failSecond = reject;
410
+ });
411
+ let calls = 0;
412
+ adapter.ensureInitialized = async (modelId, onProgress) => {
413
+ calls += 1;
414
+ if (calls === 1) {
415
+ await firstGate;
416
+ return inner(modelId, onProgress);
417
+ }
418
+ await secondGate;
419
+ return inner(modelId, onProgress);
420
+ };
421
+ const backend = createWebLlmInferenceBackend({ adapter });
422
+ const discarded = backend.load();
423
+ await backend.unload();
424
+ const second = backend.load();
425
+ const secondSettled = expect(second).rejects.toThrow('second init failed');
426
+ // The first attempt finishes while the second is in flight, so it must NOT
427
+ // release then — the second owns the adapter.
428
+ releaseFirst();
429
+ await discarded;
430
+ // Now the second fails: nothing else can discharge the deferred release, so
431
+ // a fully initialized model would stay resident with no owner.
432
+ failSecond(new Error('second init failed'));
433
+ await secondSettled;
434
+ expect(adapter.calls.unload).toBe(2);
435
+ // The release above resets the adapter's own state, so the failure has to be
436
+ // durable in the backend or this reads `idle` — "never loaded" — and the
437
+ // control offers Load instead of Retry.
438
+ expect(backend.status).toBe('error');
439
+ expect(progressOf(backend)).toMatchObject({ state: 'error' });
440
+ });
441
+ it('does not resurrect a model a later unload released', async () => {
442
+ withWebGpu();
443
+ const adapter = makeAdapter();
444
+ const inner = adapter.ensureInitialized.bind(adapter);
445
+ let releaseCold = () => { };
446
+ const coldGate = new Promise((resolve) => {
447
+ releaseCold = resolve;
448
+ });
449
+ let calls = 0;
450
+ adapter.ensureInitialized = async (modelId, onProgress) => {
451
+ calls += 1;
452
+ // The first attempt stays cold, so it finishes after everything else.
453
+ if (calls === 1)
454
+ await coldGate;
455
+ return inner(modelId, onProgress);
456
+ };
457
+ const backend = createWebLlmInferenceBackend({ adapter });
458
+ const cold = backend.load();
459
+ await backend.unload();
460
+ await backend.load();
461
+ await backend.unload();
462
+ releaseCold();
463
+ await cold;
464
+ // The cold attempt re-established the model on the shared adapter; since
465
+ // the caller released it again, it must not be left resident and `ready`.
466
+ expect(backend.status).toBe('idle');
467
+ });
468
+ it('notifies subscribers on a load transition', async () => {
469
+ withWebGpu();
470
+ const backend = createWebLlmInferenceBackend({ adapter: makeAdapter() });
471
+ const listener = vi.fn();
472
+ const unsubscribe = backend.subscribe(listener);
473
+ await backend.load();
474
+ expect(listener).toHaveBeenCalled();
475
+ unsubscribe();
476
+ });
477
+ it('unsubscribing stops notifications', async () => {
478
+ withWebGpu();
479
+ const backend = createWebLlmInferenceBackend({ adapter: makeAdapter() });
480
+ const listener = vi.fn();
481
+ backend.subscribe(listener)();
482
+ await backend.load();
483
+ expect(listener).not.toHaveBeenCalled();
484
+ });
485
+ });
486
+ describe('createWebLlmInferenceBackend adapter construction', () => {
487
+ beforeEach(() => {
488
+ Object.defineProperty(globalThis, 'navigator', {
489
+ value: { ...globalThis.navigator, gpu: {} },
490
+ configurable: true,
491
+ });
492
+ getLLMMock.mockReset();
493
+ });
494
+ afterEach(() => {
495
+ if (originalNavigator) {
496
+ Object.defineProperty(globalThis, 'navigator', originalNavigator);
497
+ }
498
+ });
499
+ it('forwards a supplied module loader to the adapter factory', async () => {
500
+ const adapter = makeAdapter();
501
+ getLLMMock.mockResolvedValue(adapter);
502
+ const loadModule = () => Promise.resolve({ CreateMLCEngine: vi.fn() });
503
+ // A browser cannot resolve a bare specifier, so this passthrough is the
504
+ // only way a host that owns `@mlc-ai/web-llm` can make the local backend
505
+ // work at all.
506
+ await createWebLlmInferenceBackend({ loadModule }).chat([
507
+ { role: 'user', content: 'hi' },
508
+ ]);
509
+ expect(getLLMMock).toHaveBeenCalledWith({ loadModule });
510
+ });
511
+ it('pins the configured model as the adapter default', async () => {
512
+ const adapter = makeAdapter();
513
+ getLLMMock.mockResolvedValue(adapter);
514
+ // A turn that names no model resolves the adapter's DEFAULT, so the model
515
+ // `load()` ensures has to be that default — otherwise the first turn unloads
516
+ // it and downloads another behind the message.
517
+ await createWebLlmInferenceBackend({ model: 'wanted' }).chat([
518
+ { role: 'user', content: 'hi' },
519
+ ]);
520
+ expect(getLLMMock).toHaveBeenCalledWith({ defaultModel: 'wanted' });
521
+ });
522
+ it('targets the ensured model on a turn, not the adapter default', async () => {
523
+ withWebGpu();
524
+ const adapter = makeAdapter();
525
+ adapter.setInitState('ready');
526
+ adapter.currentModel = 'wanted';
527
+ const backend = createWebLlmInferenceBackend({ adapter, model: 'wanted' });
528
+ await backend.chat([{ role: 'user', content: 'hi' }]);
529
+ // A turn that names no model must resolve the one this backend ensures: the
530
+ // adapter would otherwise fall back to its own default, unload the model
531
+ // `load()` bound, and download another behind the message.
532
+ expect(adapter.calls.chat.at(-1)?.options).toMatchObject({
533
+ model: 'wanted',
534
+ });
535
+ });
536
+ it('does not let a superseded failure release the current model', async () => {
537
+ withWebGpu();
538
+ const adapter = makeAdapter();
539
+ const inner = adapter.ensureInitialized.bind(adapter);
540
+ const gates = [];
541
+ let calls = 0;
542
+ adapter.ensureInitialized = async (modelId, onProgress) => {
543
+ calls += 1;
544
+ const attempt = calls;
545
+ await new Promise((resolve) => {
546
+ gates[attempt] = resolve;
547
+ });
548
+ // The middle attempt fails AFTER the third one has published.
549
+ if (attempt === 2)
550
+ throw new Error('second init failed');
551
+ return inner(modelId, onProgress);
552
+ };
553
+ const backend = createWebLlmInferenceBackend({ adapter });
554
+ const first = backend.load();
555
+ await backend.unload();
556
+ const second = backend.load();
557
+ await backend.unload();
558
+ const third = backend.load();
559
+ // Microtask-driven (no wall clock): wait until each attempt has reached its
560
+ // await before releasing any gate.
561
+ const until = async (ready) => {
562
+ for (let i = 0; i < 100 && !ready(); i += 1)
563
+ await Promise.resolve();
564
+ };
565
+ await until(() => typeof gates[3] === 'function');
566
+ // The oldest attempt finishes while the third is in flight, so it defers.
567
+ gates[1]();
568
+ await until(() => typeof gates[3] === 'function');
569
+ // The third publishes the model this backend now owns.
570
+ gates[3]();
571
+ await third;
572
+ // The middle attempt then fails: its epoch is superseded, so it says
573
+ // nothing about who owns the adapter now.
574
+ gates[2]();
575
+ await second.catch(() => undefined);
576
+ await first.catch(() => undefined);
577
+ expect(backend.status).toBe('ready');
578
+ });
579
+ it('does not orphan a model a newer attempt already published', async () => {
580
+ withWebGpu();
581
+ const adapter = makeAdapter();
582
+ const inner = adapter.ensureInitialized.bind(adapter);
583
+ const gates = [];
584
+ let calls = 0;
585
+ adapter.ensureInitialized = async (modelId, onProgress) => {
586
+ calls += 1;
587
+ const attempt = calls;
588
+ if (attempt === 3)
589
+ throw new Error('third init failed');
590
+ await new Promise((resolve) => {
591
+ gates[attempt] = resolve;
592
+ });
593
+ return inner(modelId, onProgress);
594
+ };
595
+ // A configured model the adapter does not hold keeps `load()` from
596
+ // short-circuiting on `ready`, so the third attempt really runs.
597
+ const backend = createWebLlmInferenceBackend({
598
+ adapter,
599
+ model: 'wanted',
600
+ });
601
+ const until = async (ready) => {
602
+ for (let i = 0; i < 100 && !ready(); i += 1)
603
+ await Promise.resolve();
604
+ };
605
+ const first = backend.load();
606
+ await backend.unload();
607
+ const second = backend.load();
608
+ await until(() => typeof gates[2] === 'function');
609
+ // The newer attempt publishes first, so the adapter's ONE model is its own.
610
+ gates[2]();
611
+ await second;
612
+ // The stale attempt then completes — it must not claim that model.
613
+ gates[1]();
614
+ await first.catch(() => undefined);
615
+ const unloads = adapter.calls.unload;
616
+ await backend.load().catch(() => undefined);
617
+ // Only the explicit unload released anything: the failed load must not
618
+ // dispose the model the second attempt still owns.
619
+ expect(adapter.calls.unload).toBe(unloads);
620
+ });
621
+ it('still loads when the shared adapter is ready on another model', async () => {
622
+ withWebGpu();
623
+ const adapter = makeAdapter();
624
+ adapter.setInitState('ready');
625
+ adapter.currentModel = 'other-model';
626
+ const backend = createWebLlmInferenceBackend({ adapter, model: 'wanted' });
627
+ await backend.load();
628
+ // `ready` on a DIFFERENT model must not short-circuit: the caller asked for
629
+ // this backend's model, and skipping would hand the turn to the model the
630
+ // adapter happens to hold.
631
+ expect(adapter.calls.ensure).toEqual(['wanted']);
632
+ });
633
+ it('constructs the adapter once for concurrent callers', async () => {
634
+ const adapter = makeAdapter();
635
+ getLLMMock.mockResolvedValue(adapter);
636
+ const backend = createWebLlmInferenceBackend({});
637
+ await Promise.all([
638
+ backend.chat([{ role: 'user', content: 'a' }]),
639
+ backend.chat([{ role: 'user', content: 'b' }]),
640
+ backend.load(),
641
+ ]);
642
+ // Two callers must not each build an unshared adapter — and, with it, start
643
+ // a second multi-hundred-megabyte download.
644
+ expect(getLLMMock).toHaveBeenCalledTimes(1);
645
+ });
646
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"webllm.d.ts","sourceRoot":"","sources":["../../../../src/browser-ai/adapters/llm/webllm.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAQH,OAAO,KAAK,EAEV,SAAS,EACT,UAAU,EACX,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EACV,UAAU,EACV,eAAe,EACf,cAAc,EACd,UAAU,EAEV,WAAW,EACX,aAAa,EACd,MAAM,YAAY,CAAC;AAwDpB;;GAEG;AACH,qBAAa,aAAc,YAAW,UAAU;IAC9C,QAAQ,CAAC,IAAI,EAAG,QAAQ,CAAU;IAElC,OAAO,CAAC,UAAU,CAA8B;IAChD,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,OAAO,CAAgB;IAG/B,OAAO,CAAC,MAAM,CAA6B;gBAE/B,OAAO,GAAE,aAAkB;IAOvC,IAAI,SAAS,IAAI,SAAS,CAEzB;IAED,IAAI,YAAY,IAAI,MAAM,GAAG,IAAI,CAEhC;IAEK,iBAAiB,CACrB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,GACtB,OAAO,CAAC,IAAI,CAAC;YAoFF,YAAY;IAWpB,eAAe,IAAI,OAAO,CAAC,eAAe,CAAC;IAsB3C,IAAI,CACR,QAAQ,EAAE,UAAU,EAAE,EACtB,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,WAAW,CAAC;IAoChB,MAAM,CACX,QAAQ,EAAE,UAAU,EAAE,EACtB,OAAO,GAAE,cAAmB,GAC3B,aAAa,CAAC,MAAM,CAAC;IA+BlB,OAAO,CACX,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAc,GAAG;QAAE,YAAY,CAAC,EAAE,MAAM,CAAA;KAAO,GACvD,OAAO,CAAC,MAAM,CAAC;IAmBZ,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAM1C,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAa5B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAG/B"}
1
+ {"version":3,"file":"webllm.d.ts","sourceRoot":"","sources":["../../../../src/browser-ai/adapters/llm/webllm.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAQH,OAAO,KAAK,EAEV,SAAS,EACT,UAAU,EACX,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EACV,UAAU,EACV,eAAe,EACf,cAAc,EACd,UAAU,EAEV,WAAW,EACX,aAAa,EACd,MAAM,YAAY,CAAC;AAwDpB;;GAEG;AACH,qBAAa,aAAc,YAAW,UAAU;IAC9C,QAAQ,CAAC,IAAI,EAAG,QAAQ,CAAU;IAElC,OAAO,CAAC,UAAU,CAA8B;IAChD,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,OAAO,CAAgB;IAG/B,OAAO,CAAC,MAAM,CAA6B;gBAE/B,OAAO,GAAE,aAAkB;IAOvC,IAAI,SAAS,IAAI,SAAS,CAEzB;IAED,IAAI,YAAY,IAAI,MAAM,GAAG,IAAI,CAEhC;IAEK,iBAAiB,CACrB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,GACtB,OAAO,CAAC,IAAI,CAAC;YA6FF,YAAY;IAuBpB,eAAe,IAAI,OAAO,CAAC,eAAe,CAAC;IAsB3C,IAAI,CACR,QAAQ,EAAE,UAAU,EAAE,EACtB,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,WAAW,CAAC;IAoChB,MAAM,CACX,QAAQ,EAAE,UAAU,EAAE,EACtB,OAAO,GAAE,cAAmB,GAC3B,aAAa,CAAC,MAAM,CAAC;IA+BlB,OAAO,CACX,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAc,GAAG;QAAE,YAAY,CAAC,EAAE,MAAM,CAAA;KAAO,GACvD,OAAO,CAAC,MAAM,CAAC;IAmBZ,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAM1C,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAa5B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAG/B"}