@happyvertical/smrt-svelte 0.53.1 → 0.53.3
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/package.json +15 -15
- package/dist/__tests__/app-state.test.js +0 -156
- package/dist/__tests__/data-surface-bridge.test.js +0 -351
- package/dist/__tests__/provider-mode-harness.svelte +0 -14
- package/dist/__tests__/provider-mode-harness.svelte.d.ts +0 -11
- package/dist/__tests__/provider-mode-harness.svelte.d.ts.map +0 -1
- package/dist/__tests__/provider-mode-probe.svelte +0 -13
- package/dist/__tests__/provider-mode-probe.svelte.d.ts +0 -8
- package/dist/__tests__/provider-mode-probe.svelte.d.ts.map +0 -1
- package/dist/__tests__/provider-webmcp-harness.svelte +0 -14
- package/dist/__tests__/provider-webmcp-harness.svelte.d.ts +0 -8
- package/dist/__tests__/provider-webmcp-harness.svelte.d.ts.map +0 -1
- package/dist/__tests__/success-never-text.test.js +0 -100
- package/dist/__tests__/warm-clients.test.js +0 -186
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.test.js +0 -464
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.test.js +0 -646
- package/dist/browser-ai/adapters/stt/browser-speech.test.js +0 -113
- package/dist/browser-ai/adapters/stt/dictation-source.test.js +0 -52
- package/dist/browser-ai/svelte/components/__tests__/ModelStatusControl.test.js +0 -172
- package/dist/components/board/__tests__/Board.test.js +0 -498
- package/dist/components/board/__tests__/BoardSsrHarness.svelte +0 -30
- package/dist/components/board/__tests__/BoardSsrHarness.svelte.d.ts +0 -19
- package/dist/components/board/__tests__/BoardSsrHarness.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/AddressInput.behavior.test.js +0 -216
- package/dist/components/forms/__tests__/CheckboxInput.test.js +0 -92
- package/dist/components/forms/__tests__/DateRangeInput.behavior.test.js +0 -135
- package/dist/components/forms/__tests__/DateTimeInput.behavior.test.js +0 -113
- package/dist/components/forms/__tests__/FileUpload.error-alert.test.js +0 -37
- package/dist/components/forms/__tests__/FileUpload.native-form.test.js +0 -249
- package/dist/components/forms/__tests__/FileUpload.test.js +0 -90
- package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte +0 -24
- package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts +0 -10
- package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/Form.behavior.test.js +0 -137
- package/dist/components/forms/__tests__/Form.extraction.test.js +0 -68
- package/dist/components/forms/__tests__/Form.stt-error.test.js +0 -57
- package/dist/components/forms/__tests__/Form.test.js +0 -86
- package/dist/components/forms/__tests__/Form.ui-controls.test.js +0 -119
- package/dist/components/forms/__tests__/Form.webmcp.test.js +0 -2336
- package/dist/components/forms/__tests__/FormMicButton.test.js +0 -92
- package/dist/components/forms/__tests__/FormScope.test.js +0 -39
- package/dist/components/forms/__tests__/MeasurementInput.behavior.test.js +0 -129
- package/dist/components/forms/__tests__/MoneyInput.behavior.test.js +0 -124
- package/dist/components/forms/__tests__/NumberInput.behavior.test.js +0 -151
- package/dist/components/forms/__tests__/PhoneInput.behavior.test.js +0 -96
- package/dist/components/forms/__tests__/SearchInput.test.js +0 -79
- package/dist/components/forms/__tests__/SelectInput.behavior.test.js +0 -132
- package/dist/components/forms/__tests__/TextInput.behavior.test.js +0 -131
- package/dist/components/forms/__tests__/TextareaInput.behavior.test.js +0 -96
- package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte +0 -30
- package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts +0 -7
- package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/async-validation-form.fixture.svelte +0 -19
- package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts +0 -9
- package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte +0 -21
- package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts +0 -8
- package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/composite-inputs-a11y.test.js +0 -69
- package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte +0 -27
- package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts +0 -9
- package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte +0 -48
- package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts +0 -10
- package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte +0 -26
- package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts +0 -13
- package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte +0 -52
- package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts +0 -16
- package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/form-scope.fixture.svelte +0 -30
- package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts +0 -10
- package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte +0 -158
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts +0 -51
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte +0 -24
- package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts +0 -11
- package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte +0 -105
- package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts +0 -42
- package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte +0 -84
- package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts +0 -14
- package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte +0 -44
- package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts +0 -9
- package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/mic-keyboard-a11y.test.js +0 -74
- package/dist/components/forms/__tests__/policy-field.fixture.svelte +0 -35
- package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts +0 -8
- package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts.map +0 -1
- package/dist/components/forms/__tests__/rich-inputs-a11y.test.js +0 -87
- package/dist/components/settings/__tests__/SettingsCatalog.test.js +0 -69
- package/dist/components/settings/__tests__/catalog.test.js +0 -46
- package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +0 -597
- package/dist/components/workspace/__tests__/AdminShell.test.js +0 -572
- package/dist/components/workspace/__tests__/RoleShell.test.js +0 -772
- package/dist/components/workspace/__tests__/TenantNav.test.js +0 -208
- package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +0 -79
- package/dist/components/workspace/__tests__/WorkspaceShell.test.js +0 -630
- package/dist/components/workspace/__tests__/admin-shell-header-overflow.test.js +0 -25
- package/dist/components/workspace/__tests__/admin-shell-hotkeys.test.js +0 -34
- package/dist/components/workspace/__tests__/admin-shell-mobile.test.js +0 -217
- package/dist/components/workspace/__tests__/admin-shell-mutation-effect-harness.svelte +0 -52
- package/dist/components/workspace/__tests__/admin-shell-mutation-effect-harness.svelte.d.ts +0 -23
- package/dist/components/workspace/__tests__/admin-shell-mutation-effect-harness.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/admin-shell-page-contracts.test.js +0 -30
- package/dist/components/workspace/__tests__/admin-shell-responsive-state.test.js +0 -184
- package/dist/components/workspace/__tests__/admin-shell-settings.test.js +0 -25
- package/dist/components/workspace/__tests__/admin-shell-state.test.js +0 -286
- package/dist/components/workspace/__tests__/admin-shell-theme-hooks.test.js +0 -36
- package/dist/components/workspace/__tests__/breadcrumbs-helpers.test.js +0 -141
- package/dist/components/workspace/__tests__/context-forwarding-harness.svelte +0 -45
- package/dist/components/workspace/__tests__/context-forwarding-harness.svelte.d.ts +0 -21
- package/dist/components/workspace/__tests__/context-forwarding-harness.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/define-tools-dock.test.js +0 -1188
- package/dist/components/workspace/__tests__/harness.svelte +0 -25
- package/dist/components/workspace/__tests__/harness.svelte.d.ts +0 -14
- package/dist/components/workspace/__tests__/harness.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/index.test.js +0 -28
- package/dist/components/workspace/__tests__/legacy-index.test.js +0 -9
- package/dist/components/workspace/__tests__/manifest-nav-helpers.test.js +0 -24
- package/dist/components/workspace/__tests__/manifest-nav.test.js +0 -599
- package/dist/components/workspace/__tests__/nav-helpers.test.js +0 -95
- package/dist/components/workspace/__tests__/render-harness.svelte +0 -66
- package/dist/components/workspace/__tests__/render-harness.svelte.d.ts +0 -32
- package/dist/components/workspace/__tests__/render-harness.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/render-tools-dock.test.js +0 -260
- package/dist/components/workspace/__tests__/role-shell-bind-harness.svelte +0 -58
- package/dist/components/workspace/__tests__/role-shell-bind-harness.svelte.d.ts +0 -16
- package/dist/components/workspace/__tests__/role-shell-bind-harness.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/role-shell-switch-harness.svelte +0 -41
- package/dist/components/workspace/__tests__/role-shell-switch-harness.svelte.d.ts +0 -13
- package/dist/components/workspace/__tests__/role-shell-switch-harness.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/shell-chrome-components.test.js +0 -108
- package/dist/components/workspace/__tests__/system-feed.test.js +0 -327
- package/dist/components/workspace/__tests__/test-icon.svelte +0 -17
- package/dist/components/workspace/__tests__/test-icon.svelte.d.ts +0 -19
- package/dist/components/workspace/__tests__/test-icon.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/typed-tool-fixture/TypedTool.svelte +0 -38
- package/dist/components/workspace/__tests__/typed-tool-fixture/TypedTool.svelte.d.ts +0 -22
- package/dist/components/workspace/__tests__/typed-tool-fixture/TypedTool.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/typed-tool-fixture/register-typed-tool.d.ts +0 -66
- package/dist/components/workspace/__tests__/typed-tool-fixture/register-typed-tool.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/typed-tool-fixture/register-typed-tool.js +0 -115
- package/dist/components/workspace/__tests__/typed-tool-fixture/typed-tool-types.d.ts +0 -15
- package/dist/components/workspace/__tests__/typed-tool-fixture/typed-tool-types.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/typed-tool-fixture/typed-tool-types.js +0 -7
- package/dist/components/workspace/__tests__/typed-tool-fixture.test.js +0 -115
- package/dist/components/workspace/__tests__/use-harness-orphan.svelte +0 -9
- package/dist/components/workspace/__tests__/use-harness-orphan.svelte.d.ts +0 -19
- package/dist/components/workspace/__tests__/use-harness-orphan.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/use-harness.svelte +0 -23
- package/dist/components/workspace/__tests__/use-harness.svelte.d.ts +0 -8
- package/dist/components/workspace/__tests__/use-harness.svelte.d.ts.map +0 -1
- package/dist/components/workspace/__tests__/use-tools-dock.test.js +0 -33
- package/dist/components/workspace/__tests__/workspace-shell-bind-harness.svelte +0 -43
- package/dist/components/workspace/__tests__/workspace-shell-bind-harness.svelte.d.ts +0 -11
- package/dist/components/workspace/__tests__/workspace-shell-bind-harness.svelte.d.ts.map +0 -1
- package/dist/components/workspace/server/__tests__/compose-availability.test.js +0 -383
- package/dist/components/workspace/server/__tests__/typed-context-fixture.d.ts +0 -78
- package/dist/components/workspace/server/__tests__/typed-context-fixture.d.ts.map +0 -1
- package/dist/components/workspace/server/__tests__/typed-context-fixture.js +0 -104
- package/dist/hooks/__tests__/stt-consumer.fixture.svelte +0 -25
- package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts +0 -15
- package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts.map +0 -1
- package/dist/hooks/__tests__/stt-ownership-harness.svelte +0 -42
- package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts +0 -20
- package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts.map +0 -1
- package/dist/hooks/__tests__/useSTT-ownership.test.js +0 -102
- package/dist/i18n/__tests__/server.spec.js +0 -57
- package/dist/internal/logger.test.js +0 -54
- package/dist/provider-mode.test.js +0 -86
- package/dist/provider-webmcp.test.js +0 -244
- package/dist/state/__tests__/app-state-ai-lifecycle.test.js +0 -240
- package/dist/state/__tests__/warm-clients.test.js +0 -40
- package/dist/web/__tests__/activity-feed-harness.svelte +0 -32
- package/dist/web/__tests__/activity-feed-harness.svelte.d.ts +0 -18
- package/dist/web/__tests__/activity-feed-harness.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/activity-feed.integration.svelte.test.js +0 -190
- package/dist/web/__tests__/activity-feed.svelte.test.js +0 -235
- package/dist/web/__tests__/assistant-dock.integration.svelte.test.js +0 -204
- package/dist/web/__tests__/data-surface-conformance.integration.svelte.test.js +0 -1392
- package/dist/web/__tests__/harness.svelte +0 -25
- package/dist/web/__tests__/harness.svelte.d.ts +0 -15
- package/dist/web/__tests__/harness.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/list-data-surface.fixture.svelte +0 -59
- package/dist/web/__tests__/list-data-surface.fixture.svelte.d.ts +0 -14
- package/dist/web/__tests__/list-data-surface.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/list-data-surface.svelte.test.js +0 -106
- package/dist/web/__tests__/list-data-surface.test.js +0 -651
- package/dist/web/__tests__/list-sort-surface.fixture.svelte +0 -41
- package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts +0 -15
- package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/list-sort-surface.svelte.test.js +0 -59
- package/dist/web/__tests__/live-collection.svelte.test.js +0 -185
- package/dist/web/__tests__/provider-ui-registry.fixture.svelte +0 -43
- package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts +0 -13
- package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/provider-ui-registry.test.js +0 -235
- package/dist/web/__tests__/remote-query-harness.svelte +0 -27
- package/dist/web/__tests__/remote-query-harness.svelte.d.ts +0 -11
- package/dist/web/__tests__/remote-query-harness.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/remote-query.svelte.test.js +0 -101
- package/dist/web/__tests__/surfaces.child.fixture.svelte +0 -69
- package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts +0 -16
- package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/surfaces.fixture.svelte +0 -26
- package/dist/web/__tests__/surfaces.fixture.svelte.d.ts +0 -18
- package/dist/web/__tests__/surfaces.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/surfaces.svelte.test.js +0 -140
- package/dist/web/__tests__/update-available-harness.svelte +0 -46
- package/dist/web/__tests__/update-available-harness.svelte.d.ts +0 -22
- package/dist/web/__tests__/update-available-harness.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/update-available.svelte.test.js +0 -137
- package/dist/web/__tests__/view-intent-provider.fixture.svelte +0 -28
- package/dist/web/__tests__/view-intent-provider.fixture.svelte.d.ts +0 -13
- package/dist/web/__tests__/view-intent-provider.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/view-intent.fixture.svelte +0 -33
- package/dist/web/__tests__/view-intent.fixture.svelte.d.ts +0 -15
- package/dist/web/__tests__/view-intent.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/view-intent.intents.d.ts +0 -14
- package/dist/web/__tests__/view-intent.intents.d.ts.map +0 -1
- package/dist/web/__tests__/view-intent.intents.js +0 -38
- package/dist/web/__tests__/view-intent.svelte.test.js +0 -244
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte +0 -73
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts +0 -8
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/webmcp-composed.fixture.svelte +0 -52
- package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts +0 -12
- package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/webmcp-composed.integration.svelte.test.js +0 -251
- package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte +0 -16
- package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte.d.ts +0 -19
- package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte +0 -11
- package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte.d.ts +0 -8
- package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte +0 -20
- package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte.d.ts +0 -7
- package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/webmcp-harness-unannotated.svelte +0 -14
- package/dist/web/__tests__/webmcp-harness-unannotated.svelte.d.ts +0 -19
- package/dist/web/__tests__/webmcp-harness-unannotated.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/webmcp-harness.svelte +0 -18
- package/dist/web/__tests__/webmcp-harness.svelte.d.ts +0 -7
- package/dist/web/__tests__/webmcp-harness.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/webmcp-ssr.fixture.svelte +0 -13
- package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts +0 -19
- package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts.map +0 -1
- package/dist/web/__tests__/webmcp-ui.test.js +0 -723
- package/dist/web/__tests__/webmcp.test.js +0 -234
|
@@ -1,646 +0,0 @@
|
|
|
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
|
-
});
|