@happyvertical/smrt-svelte 0.51.11 → 0.51.14
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/AGENTS.md +27 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts +129 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.js +331 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.test.js +464 -0
- package/dist/browser-ai/adapters/llm/index.d.ts +2 -0
- package/dist/browser-ai/adapters/llm/index.d.ts.map +1 -1
- package/dist/browser-ai/adapters/llm/index.js +2 -0
- package/dist/browser-ai/adapters/llm/types.d.ts +18 -0
- package/dist/browser-ai/adapters/llm/types.d.ts.map +1 -1
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts +67 -0
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.js +364 -0
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.test.js +646 -0
- package/dist/browser-ai/adapters/llm/webllm.d.ts.map +1 -1
- package/dist/browser-ai/adapters/llm/webllm.js +19 -2
- package/dist/browser-ai/svelte/components/ModelStatusControl.svelte +190 -0
- package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts +17 -0
- package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts.map +1 -0
- package/dist/browser-ai/svelte/components/__tests__/ModelStatusControl.test.js +172 -0
- package/dist/browser-ai/svelte/index.d.ts +2 -1
- package/dist/browser-ai/svelte/index.d.ts.map +1 -1
- package/dist/browser-ai/svelte/index.js +3 -1
- package/dist/i18n/strings.browser-ai.d.ts +4 -0
- package/dist/i18n/strings.browser-ai.d.ts.map +1 -0
- package/dist/i18n/strings.browser-ai.js +4 -0
- package/package.json +30 -26
package/AGENTS.md
CHANGED
|
@@ -195,6 +195,33 @@ for a data-surface one — for exactly the component's lifetime.
|
|
|
195
195
|
- **Warm client cache**: module-level Map survives navigation/remounts -- avoids re-downloading WASM/models
|
|
196
196
|
- **Adapters**: STT (browser-speech, whisper-cpp, whisper-wasm), TTS (browser-synthesis), LLM (webllm, transformers-llm)
|
|
197
197
|
- Cache API: `getCachedSTT()`, `getCachedTTS()`, `getCachedLLM()`, `getCacheStats()`, `clearAllCaches()`
|
|
198
|
+
- **Inference backends (browser-ai)**: two `@happyvertical/smrt-web/ai` `InferenceBackend`s, both
|
|
199
|
+
`kind: 'local'`, both refusing `tool`/`function` messages, non-string content, `tool_calls` and
|
|
200
|
+
`options.tools` with `InferencePathError` (`unsupported_message`) through the SHARED
|
|
201
|
+
`assertTextOnlyTurn`, so a per-call switch between backends never changes what a turn means.
|
|
202
|
+
- `createWebLlmInferenceBackend()` wraps an `LLMAdapter` — `status` derives from the adapter's
|
|
203
|
+
`initState` (plus an explicit in-flight flag, because the adapter only flips to `initializing`
|
|
204
|
+
after its own awaits), `progress` mirrors `DownloadProgressInfo`, and `load()`/`unload()` drive
|
|
205
|
+
`ensureInitialized`/`unloadModel`. It reports `unavailable` without WebGPU so an `auto` path skips
|
|
206
|
+
it. Pass `adapter` to share the app-state-managed warm-cached adapter; construct once, so two
|
|
207
|
+
callers cannot each start an unshared adapter and a second download.
|
|
208
|
+
Its `loadModule` option matters: the default `importOptional()` resolves a VARIABLE specifier,
|
|
209
|
+
which a browser cannot resolve (`TypeError: Failed to resolve module specifier`) — a host that has
|
|
210
|
+
`@mlc-ai/web-llm` must pass `() => import('@mlc-ai/web-llm')` so the bundler rewrites a static one.
|
|
211
|
+
It does NOT forward `signal` (the adapter has no cancellation seam).
|
|
212
|
+
- `createBitGpuInferenceBackend()` wraps `bitgpu` (MIT, optional peer), the engine behind the
|
|
213
|
+
Bonsai WebGPU demo PrismML's own docs list as their in-browser option. `loadBitGpu`/`loadChat`
|
|
214
|
+
are REQUIRED loaders (`() => import('bitgpu')`, `() => import('bitgpu/chat')`) — a static import
|
|
215
|
+
here would fail the build for every consumer without the optional peer, and a bare specifier is
|
|
216
|
+
not resolvable by a browser either. `bitgpu`'s `LoadProgress.phase` maps to `InferenceProgress`
|
|
217
|
+
(`manifest`/`weights` → `downloading`, `pipelines` → `extracting`). Unlike the WebLLM backend it
|
|
218
|
+
**forwards `signal`** — bitgpu aborts a live generation — and it does NOT lazily load: weights
|
|
219
|
+
run to GBs, so a turn before `load()` fails with a named reason. `model` in the per-call options
|
|
220
|
+
is ignored (one engine binds one model), and bitgpu's tools/JSON-schema/think support is
|
|
221
|
+
deliberately left unexposed for now so the text-only invariant holds across backends.
|
|
222
|
+
- **`ModelStatusControl.svelte`**: the user-facing model lifecycle surface (state, Load/Unload,
|
|
223
|
+
download progress) driven entirely by a backend's own `status`/`progress`/`subscribe`, so it works
|
|
224
|
+
without a `<Provider>` ancestor. `unavailable` is a first-class state that offers NO load action.
|
|
198
225
|
|
|
199
226
|
## Permission Action
|
|
200
227
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `InferenceBackend` over `bitgpu` — a WebGPU runtime for 1-bit (binary-weight)
|
|
3
|
+
* LLMs in the browser.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this engine and not WebLLM
|
|
6
|
+
*
|
|
7
|
+
* `bitgpu` (MIT, zero runtime dependencies) is the engine behind the Bonsai
|
|
8
|
+
* WebGPU demo PrismML's own docs list as their in-browser option, and it is the
|
|
9
|
+
* one browser runtime in this package that can honor `signal`: its
|
|
10
|
+
* `ChatSendOptions.signal` aborts a generation in flight, which the WebLLM
|
|
11
|
+
* adapter has no seam for. It also reports load progress by phase and reuses the
|
|
12
|
+
* KV cache across turns.
|
|
13
|
+
*
|
|
14
|
+
* ## What this backend does not expose (yet)
|
|
15
|
+
*
|
|
16
|
+
* `bitgpu` supports tool calling, JSON-schema-constrained decoding, thinking
|
|
17
|
+
* budgets and vision. This backend deliberately exposes only the TEXT subset
|
|
18
|
+
* that {@link assertTextOnlyTurn} enforces, so that switching backend per call
|
|
19
|
+
* never changes what a turn means: the WebLLM and route backends refuse tool
|
|
20
|
+
* turns, and a backend that silently accepted them would break that invariant.
|
|
21
|
+
* Widening this is a deliberate follow-up, not an oversight.
|
|
22
|
+
*
|
|
23
|
+
* `model` in {@link InferenceChatOptions} is ignored: one engine is bound to one
|
|
24
|
+
* model (`createEngine`), so a per-call model id has nothing to select.
|
|
25
|
+
*
|
|
26
|
+
* ## Loading
|
|
27
|
+
*
|
|
28
|
+
* Unlike the WebLLM backend, `chat`/`stream` do NOT lazily load. Weights for
|
|
29
|
+
* these models run from hundreds of MB to multiple GB, so an implicit download
|
|
30
|
+
* behind the first turn would be hostile; a caller must `load()` first (the path
|
|
31
|
+
* only auto-selects a backend whose status is `ready`, so `auto` already
|
|
32
|
+
* requires it). Calling a turn before loading fails with a clear message.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
import { type InferenceBackend } from '@happyvertical/smrt-web/ai';
|
|
37
|
+
/** Mirrors `LoadProgress`. Exported so a host or test can type the callback. */
|
|
38
|
+
export interface BitGpuLoadProgress {
|
|
39
|
+
phase: 'manifest' | 'weights' | 'pipelines';
|
|
40
|
+
loaded?: number;
|
|
41
|
+
total?: number;
|
|
42
|
+
}
|
|
43
|
+
/** Mirrors `ChatMessage`. */
|
|
44
|
+
interface BitGpuMessage {
|
|
45
|
+
role: string;
|
|
46
|
+
content: string;
|
|
47
|
+
}
|
|
48
|
+
/** Mirrors `ChatResult`. */
|
|
49
|
+
interface BitGpuChatResult {
|
|
50
|
+
text: string;
|
|
51
|
+
finishReason: 'stop' | 'length' | 'abort' | 'tool_calls';
|
|
52
|
+
}
|
|
53
|
+
/** Mirrors `ChatSendOptions` (only the fields this backend forwards). */
|
|
54
|
+
interface BitGpuSendOptions {
|
|
55
|
+
maxTokens?: number;
|
|
56
|
+
temperature?: number;
|
|
57
|
+
topP?: number;
|
|
58
|
+
stopSequences?: string[];
|
|
59
|
+
onText?: (delta: string) => void;
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
}
|
|
62
|
+
interface BitGpuEngine {
|
|
63
|
+
dispose(): void;
|
|
64
|
+
}
|
|
65
|
+
interface BitGpuChat {
|
|
66
|
+
send(messages: BitGpuMessage[], options?: BitGpuSendOptions): Promise<BitGpuChatResult>;
|
|
67
|
+
/** Deltas, with the final result as the generator's return value. */
|
|
68
|
+
stream(messages: BitGpuMessage[], options?: BitGpuSendOptions): AsyncGenerator<string, BitGpuChatResult>;
|
|
69
|
+
}
|
|
70
|
+
/** The `bitgpu` entry point (`createEngine`, errors). */
|
|
71
|
+
export interface BitGpuModule {
|
|
72
|
+
createEngine(options: string | {
|
|
73
|
+
modelUrl?: string;
|
|
74
|
+
manifestUrl?: string;
|
|
75
|
+
dataUrl?: string;
|
|
76
|
+
auxUrl?: string;
|
|
77
|
+
maxSeqLen?: number;
|
|
78
|
+
kvCache?: 'f32' | 'f16' | 'q8';
|
|
79
|
+
onProgress?: (progress: BitGpuLoadProgress) => void;
|
|
80
|
+
}): Promise<BitGpuEngine>;
|
|
81
|
+
WebGPUUnavailableError: new (message: string) => Error;
|
|
82
|
+
}
|
|
83
|
+
/** The `bitgpu/chat` entry point. */
|
|
84
|
+
export interface BitGpuChatModule {
|
|
85
|
+
createChat(engine: BitGpuEngine, options: {
|
|
86
|
+
modelUrl?: string;
|
|
87
|
+
tokenizerJsonUrl?: string;
|
|
88
|
+
tokenizerConfigUrl?: string;
|
|
89
|
+
}): Promise<BitGpuChat>;
|
|
90
|
+
}
|
|
91
|
+
export interface BitGpuInferenceBackendOptions {
|
|
92
|
+
/** Backend id. Defaults to `'bitgpu'`. */
|
|
93
|
+
id?: string;
|
|
94
|
+
/** Model directory holding `manifest.json` plus its data/aux files. */
|
|
95
|
+
modelUrl?: string;
|
|
96
|
+
/** Explicit URLs, when they are not colocated. */
|
|
97
|
+
manifestUrl?: string;
|
|
98
|
+
dataUrl?: string;
|
|
99
|
+
auxUrl?: string;
|
|
100
|
+
/** Tokenizer files; default to `modelUrl` when omitted. */
|
|
101
|
+
tokenizerJsonUrl?: string;
|
|
102
|
+
tokenizerConfigUrl?: string;
|
|
103
|
+
/** KV window. Larger costs more GPU memory. */
|
|
104
|
+
maxSeqLen?: number;
|
|
105
|
+
/** KV cache precision. `q8` trades a little quality for context length. */
|
|
106
|
+
kvCache?: 'f32' | 'f16' | 'q8';
|
|
107
|
+
/**
|
|
108
|
+
* Supplies the `bitgpu` entry point (`createEngine`).
|
|
109
|
+
*
|
|
110
|
+
* Required, not optional: `bitgpu` is an OPTIONAL peer dependency, so a static
|
|
111
|
+
* import here would fail the build for every consumer that has not installed
|
|
112
|
+
* it — and a bare specifier is not resolvable by a browser at runtime either.
|
|
113
|
+
* The host supplies a static import from its own code, where the bundler can
|
|
114
|
+
* see it: `() => import('bitgpu')`.
|
|
115
|
+
*/
|
|
116
|
+
loadBitGpu: () => Promise<BitGpuModule>;
|
|
117
|
+
/** Supplies the `bitgpu/chat` entry point. See {@link BitGpuInferenceBackendOptions.loadBitGpu}. */
|
|
118
|
+
loadChat: () => Promise<BitGpuChatModule>;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A local backend over `bitgpu`.
|
|
122
|
+
*
|
|
123
|
+
* `status` reports `'unavailable'` without WebGPU, so an `auto` path skips it
|
|
124
|
+
* rather than starting a multi-gigabyte download on a device that cannot run the
|
|
125
|
+
* result. `load()` must be called before a turn; see the module header.
|
|
126
|
+
*/
|
|
127
|
+
export declare function createBitGpuInferenceBackend(options: BitGpuInferenceBackendOptions): InferenceBackend;
|
|
128
|
+
export {};
|
|
129
|
+
//# sourceMappingURL=bitgpu-inference-backend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bitgpu-inference-backend.d.ts","sourceRoot":"","sources":["../../../../src/browser-ai/adapters/llm/bitgpu-inference-backend.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `InferenceBackend` over `bitgpu` — a WebGPU runtime for 1-bit (binary-weight)
|
|
3
|
+
* LLMs in the browser.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this engine and not WebLLM
|
|
6
|
+
*
|
|
7
|
+
* `bitgpu` (MIT, zero runtime dependencies) is the engine behind the Bonsai
|
|
8
|
+
* WebGPU demo PrismML's own docs list as their in-browser option, and it is the
|
|
9
|
+
* one browser runtime in this package that can honor `signal`: its
|
|
10
|
+
* `ChatSendOptions.signal` aborts a generation in flight, which the WebLLM
|
|
11
|
+
* adapter has no seam for. It also reports load progress by phase and reuses the
|
|
12
|
+
* KV cache across turns.
|
|
13
|
+
*
|
|
14
|
+
* ## What this backend does not expose (yet)
|
|
15
|
+
*
|
|
16
|
+
* `bitgpu` supports tool calling, JSON-schema-constrained decoding, thinking
|
|
17
|
+
* budgets and vision. This backend deliberately exposes only the TEXT subset
|
|
18
|
+
* that {@link assertTextOnlyTurn} enforces, so that switching backend per call
|
|
19
|
+
* never changes what a turn means: the WebLLM and route backends refuse tool
|
|
20
|
+
* turns, and a backend that silently accepted them would break that invariant.
|
|
21
|
+
* Widening this is a deliberate follow-up, not an oversight.
|
|
22
|
+
*
|
|
23
|
+
* `model` in {@link InferenceChatOptions} is ignored: one engine is bound to one
|
|
24
|
+
* model (`createEngine`), so a per-call model id has nothing to select.
|
|
25
|
+
*
|
|
26
|
+
* ## Loading
|
|
27
|
+
*
|
|
28
|
+
* Unlike the WebLLM backend, `chat`/`stream` do NOT lazily load. Weights for
|
|
29
|
+
* these models run from hundreds of MB to multiple GB, so an implicit download
|
|
30
|
+
* behind the first turn would be hostile; a caller must `load()` first (the path
|
|
31
|
+
* only auto-selects a backend whose status is `ready`, so `auto` already
|
|
32
|
+
* requires it). Calling a turn before loading fails with a clear message.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
import { assertTextOnlyTurn, InferencePathError, requestSignal, } from '@happyvertical/smrt-web/ai';
|
|
37
|
+
import { detectCapabilities } from '../../capabilities/detector.js';
|
|
38
|
+
const BACKEND_LABEL = 'The bitgpu backend';
|
|
39
|
+
/** `bitgpu`'s load phases mapped onto the mirrored progress states. */
|
|
40
|
+
const PROGRESS_STATE = {
|
|
41
|
+
// Fetching the manifest and the weights is one continuous download from the
|
|
42
|
+
// caller's point of view.
|
|
43
|
+
manifest: 'downloading',
|
|
44
|
+
weights: 'downloading',
|
|
45
|
+
// Building the WGSL pipelines is the closest analogue to unpacking.
|
|
46
|
+
pipelines: 'extracting',
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Map a bitgpu load report onto {@link InferenceProgress}.
|
|
50
|
+
*
|
|
51
|
+
* `loaded`/`total` are bytes when the engine reports them; when it reports only
|
|
52
|
+
* a phase, the percent stays 0 and `currentFile` names the phase instead.
|
|
53
|
+
*/
|
|
54
|
+
function toProgress(report) {
|
|
55
|
+
const total = report.total ?? 0;
|
|
56
|
+
const loaded = report.loaded ?? 0;
|
|
57
|
+
return {
|
|
58
|
+
state: PROGRESS_STATE[report.phase] ?? 'downloading',
|
|
59
|
+
bytesLoaded: loaded,
|
|
60
|
+
bytesTotal: total,
|
|
61
|
+
percent: total > 0 ? Math.round((loaded / total) * 100) : 0,
|
|
62
|
+
currentFile: report.phase,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Forward only the options the engine can honor. */
|
|
66
|
+
function toSendOptions(options) {
|
|
67
|
+
const stop = options?.stop;
|
|
68
|
+
// bitgpu aborts a live generation, so it is the one local backend that can
|
|
69
|
+
// honor a deadline: compose it the same way the route backend does rather
|
|
70
|
+
// than silently dropping the caller's `timeout`.
|
|
71
|
+
const signal = requestSignal(options);
|
|
72
|
+
return {
|
|
73
|
+
...(options?.maxTokens !== undefined
|
|
74
|
+
? { maxTokens: options.maxTokens }
|
|
75
|
+
: {}),
|
|
76
|
+
...(options?.temperature !== undefined
|
|
77
|
+
? { temperature: options.temperature }
|
|
78
|
+
: {}),
|
|
79
|
+
...(options?.topP !== undefined ? { topP: options.topP } : {}),
|
|
80
|
+
...(stop ? { stopSequences: Array.isArray(stop) ? stop : [stop] } : {}),
|
|
81
|
+
...(signal ? { signal } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A local backend over `bitgpu`.
|
|
86
|
+
*
|
|
87
|
+
* `status` reports `'unavailable'` without WebGPU, so an `auto` path skips it
|
|
88
|
+
* rather than starting a multi-gigabyte download on a device that cannot run the
|
|
89
|
+
* result. `load()` must be called before a turn; see the module header.
|
|
90
|
+
*/
|
|
91
|
+
export function createBitGpuInferenceBackend(options) {
|
|
92
|
+
let engine = null;
|
|
93
|
+
let chat = null;
|
|
94
|
+
let progress;
|
|
95
|
+
let loadError = null;
|
|
96
|
+
let loadInFlight = false;
|
|
97
|
+
// Memoized so concurrent callers share one load. Stamped with the epoch that
|
|
98
|
+
// created it: once `unload()` invalidates an attempt, a later `load()` must
|
|
99
|
+
// start real work rather than be handed the discarded attempt's promise.
|
|
100
|
+
let loading = null;
|
|
101
|
+
// Bumped by `unload()`. A load captures it before its first `await` and
|
|
102
|
+
// re-checks it afterwards, so a load that overlaps an `unload()` discards the
|
|
103
|
+
// engine it built instead of publishing it and reporting `ready`.
|
|
104
|
+
let loadEpoch = 0;
|
|
105
|
+
const listeners = new Set();
|
|
106
|
+
function broadcast() {
|
|
107
|
+
for (const listener of [...listeners])
|
|
108
|
+
listener();
|
|
109
|
+
}
|
|
110
|
+
function currentStatus() {
|
|
111
|
+
if (!detectCapabilities().llm.webgpu)
|
|
112
|
+
return 'unavailable';
|
|
113
|
+
if (loadError)
|
|
114
|
+
return 'error';
|
|
115
|
+
if (loadInFlight)
|
|
116
|
+
return 'loading';
|
|
117
|
+
if (engine && chat)
|
|
118
|
+
return 'ready';
|
|
119
|
+
return 'idle';
|
|
120
|
+
}
|
|
121
|
+
/** The loaded chat, or a failure naming what the caller forgot to do. */
|
|
122
|
+
function requireChat() {
|
|
123
|
+
if (!chat) {
|
|
124
|
+
throw new InferencePathError(`${BACKEND_LABEL} has no loaded model. Call load() first — weights are too large to fetch behind the first turn.`, 'no_usable_backend');
|
|
125
|
+
}
|
|
126
|
+
return chat;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
id: options.id ?? 'bitgpu',
|
|
130
|
+
kind: 'local',
|
|
131
|
+
get status() {
|
|
132
|
+
return currentStatus();
|
|
133
|
+
},
|
|
134
|
+
get progress() {
|
|
135
|
+
return progress;
|
|
136
|
+
},
|
|
137
|
+
load(onProgress) {
|
|
138
|
+
// Marked before the first await: reading `status` immediately after
|
|
139
|
+
// calling `load()` must not report `idle`, the state auto-selection skips.
|
|
140
|
+
if (currentStatus() === 'unavailable') {
|
|
141
|
+
return Promise.reject(new InferencePathError('bitgpu requires WebGPU, which this browser does not expose.', 'no_usable_backend'));
|
|
142
|
+
}
|
|
143
|
+
// No-op when already ready — the documented contract for `load`. Without
|
|
144
|
+
// this, a second call fetches the weights again (hundreds of MB) and
|
|
145
|
+
// overwrites `engine` without disposing it, leaving two live WebGPU
|
|
146
|
+
// engines, while `status` flickers through `loading` and an `auto` path
|
|
147
|
+
// stops selecting this backend.
|
|
148
|
+
if (engine && chat)
|
|
149
|
+
return Promise.resolve();
|
|
150
|
+
// Reuse only a load from the CURRENT epoch: after `unload()` the memo
|
|
151
|
+
// belongs to a discarded attempt, and returning it would resolve a fresh
|
|
152
|
+
// `load()` without loading anything.
|
|
153
|
+
if (loading && loading.epoch === loadEpoch)
|
|
154
|
+
return loading.promise;
|
|
155
|
+
loadInFlight = true;
|
|
156
|
+
loadError = null;
|
|
157
|
+
broadcast();
|
|
158
|
+
// Captured before the first `await` below; every step that could publish
|
|
159
|
+
// state re-checks it, because `unload()` may run while this is in flight.
|
|
160
|
+
const epoch = loadEpoch;
|
|
161
|
+
const pending = (async () => {
|
|
162
|
+
// Hoisted: an engine created here is owned by this attempt until it is
|
|
163
|
+
// published, and anything that abandons the attempt must dispose it —
|
|
164
|
+
// nothing else holds a reference to it.
|
|
165
|
+
let created;
|
|
166
|
+
try {
|
|
167
|
+
const [bitgpu, chatModule] = await Promise.all([
|
|
168
|
+
options.loadBitGpu(),
|
|
169
|
+
options.loadChat(),
|
|
170
|
+
]);
|
|
171
|
+
created = await bitgpu.createEngine({
|
|
172
|
+
...(options.modelUrl ? { modelUrl: options.modelUrl } : {}),
|
|
173
|
+
...(options.manifestUrl
|
|
174
|
+
? { manifestUrl: options.manifestUrl }
|
|
175
|
+
: {}),
|
|
176
|
+
...(options.dataUrl ? { dataUrl: options.dataUrl } : {}),
|
|
177
|
+
...(options.auxUrl ? { auxUrl: options.auxUrl } : {}),
|
|
178
|
+
...(options.maxSeqLen !== undefined
|
|
179
|
+
? { maxSeqLen: options.maxSeqLen }
|
|
180
|
+
: {}),
|
|
181
|
+
...(options.kvCache ? { kvCache: options.kvCache } : {}),
|
|
182
|
+
onProgress: (report) => {
|
|
183
|
+
// A download that outlives an `unload()` must not resurrect
|
|
184
|
+
// progress on a backend the caller has just released.
|
|
185
|
+
if (epoch !== loadEpoch)
|
|
186
|
+
return;
|
|
187
|
+
progress = toProgress(report);
|
|
188
|
+
onProgress?.(progress);
|
|
189
|
+
broadcast();
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
// The tokenizer resolves from `modelUrl` unless told otherwise; both
|
|
193
|
+
// sides of the split are surfaced so a host can host them apart.
|
|
194
|
+
const createdChat = await chatModule.createChat(created, {
|
|
195
|
+
...(options.modelUrl ? { modelUrl: options.modelUrl } : {}),
|
|
196
|
+
...(options.tokenizerJsonUrl
|
|
197
|
+
? { tokenizerJsonUrl: options.tokenizerJsonUrl }
|
|
198
|
+
: {}),
|
|
199
|
+
...(options.tokenizerConfigUrl
|
|
200
|
+
? { tokenizerConfigUrl: options.tokenizerConfigUrl }
|
|
201
|
+
: {}),
|
|
202
|
+
});
|
|
203
|
+
if (epoch !== loadEpoch) {
|
|
204
|
+
// `unload()` ran while these were being created. Publishing them
|
|
205
|
+
// would report `ready` with a live GPU device nothing disposes, and
|
|
206
|
+
// a later `load()` would no-op on it.
|
|
207
|
+
try {
|
|
208
|
+
created.dispose();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Abandoning the attempt, not reporting its failure: a teardown
|
|
212
|
+
// error must not become the caller's error.
|
|
213
|
+
}
|
|
214
|
+
created = undefined;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
engine = created;
|
|
218
|
+
chat = createdChat;
|
|
219
|
+
// Ownership moves to the backend, so the failure path below must not
|
|
220
|
+
// dispose it.
|
|
221
|
+
created = undefined;
|
|
222
|
+
progress = undefined;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
// A failure AFTER the engine exists (the tokenizer comes from a
|
|
226
|
+
// different host than the manifest, so `createChat` failing here is
|
|
227
|
+
// the documented real case) would otherwise orphan a live GPU device
|
|
228
|
+
// and its weights — and every retry would allocate another.
|
|
229
|
+
try {
|
|
230
|
+
created?.dispose();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// A teardown error must not replace the load failure the caller is
|
|
234
|
+
// about to see, nor skip recording it.
|
|
235
|
+
}
|
|
236
|
+
created = undefined;
|
|
237
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
238
|
+
// A failure that overlaps an `unload()` leaves the backend idle
|
|
239
|
+
// rather than reporting an error for work the caller cancelled.
|
|
240
|
+
if (epoch === loadEpoch) {
|
|
241
|
+
loadError = err;
|
|
242
|
+
// Keep whatever the load had reported so a caller can see how far
|
|
243
|
+
// it got, and mark it failed.
|
|
244
|
+
progress = {
|
|
245
|
+
bytesLoaded: progress?.bytesLoaded ?? 0,
|
|
246
|
+
bytesTotal: progress?.bytesTotal ?? 0,
|
|
247
|
+
percent: progress?.percent ?? 0,
|
|
248
|
+
state: 'error',
|
|
249
|
+
error: err.message,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
// A superseded attempt must not clear the flag for the load that
|
|
256
|
+
// replaced it.
|
|
257
|
+
if (epoch === loadEpoch)
|
|
258
|
+
loadInFlight = false;
|
|
259
|
+
broadcast();
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
loading = { promise: pending, epoch };
|
|
263
|
+
// Cleared here, never inside the body above: the body runs synchronously
|
|
264
|
+
// up to its first `await`, so a host loader that throws synchronously (a
|
|
265
|
+
// legal `() => Promise<T>` callback) settles it BEFORE the assignment
|
|
266
|
+
// just above. Clearing inside would then be overwritten by that already
|
|
267
|
+
// settled promise, and every later `load()` would replay its rejection —
|
|
268
|
+
// the documented retry path dead for the life of the backend.
|
|
269
|
+
const releaseLoading = () => {
|
|
270
|
+
// Only clear the memo while it still points at THIS attempt: a later
|
|
271
|
+
// `load()` may already own it.
|
|
272
|
+
if (loading?.promise === pending)
|
|
273
|
+
loading = null;
|
|
274
|
+
};
|
|
275
|
+
void pending.then(releaseLoading, releaseLoading);
|
|
276
|
+
return pending;
|
|
277
|
+
},
|
|
278
|
+
async unload() {
|
|
279
|
+
// Invalidate anything in flight: a load re-checks the epoch after every
|
|
280
|
+
// `await` and disposes what it built rather than publishing it, and the
|
|
281
|
+
// memoized promise is dropped so a later `load()` starts real work
|
|
282
|
+
// instead of being handed the discarded attempt.
|
|
283
|
+
loadEpoch += 1;
|
|
284
|
+
loading = null;
|
|
285
|
+
loadInFlight = false;
|
|
286
|
+
// `dispose()` tears down the GPU device; the chat layer holds KV state
|
|
287
|
+
// bound to it, so both references go together.
|
|
288
|
+
engine?.dispose();
|
|
289
|
+
engine = null;
|
|
290
|
+
chat = null;
|
|
291
|
+
progress = undefined;
|
|
292
|
+
loadError = null;
|
|
293
|
+
broadcast();
|
|
294
|
+
},
|
|
295
|
+
async chat(messages, chatOptions) {
|
|
296
|
+
assertTextOnlyTurn(BACKEND_LABEL, messages, chatOptions);
|
|
297
|
+
const result = await requireChat().send(messages.map(({ role, content }) => ({ role, content })), toSendOptions(chatOptions));
|
|
298
|
+
return {
|
|
299
|
+
content: result.text,
|
|
300
|
+
// bitgpu also reports `'abort'`, which `AIResponse.finishReason` has no
|
|
301
|
+
// member for: an aborted turn carries no finish reason here rather than
|
|
302
|
+
// inventing one.
|
|
303
|
+
//
|
|
304
|
+
// The consequence is worth knowing before wiring `timeout`/`signal`: an
|
|
305
|
+
// aborted generation returns its PARTIAL text and is therefore not
|
|
306
|
+
// distinguishable from a short reply. The route backend fails closed for
|
|
307
|
+
// the same deadline (a coded `InferencePathError`), so matching that here
|
|
308
|
+
// would need a code this mirrored vocabulary does not have yet.
|
|
309
|
+
...(result.finishReason === 'stop' ||
|
|
310
|
+
result.finishReason === 'length' ||
|
|
311
|
+
result.finishReason === 'tool_calls'
|
|
312
|
+
? { finishReason: result.finishReason }
|
|
313
|
+
: {}),
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
async *stream(messages, chatOptions) {
|
|
317
|
+
assertTextOnlyTurn(BACKEND_LABEL, messages, chatOptions);
|
|
318
|
+
const iterator = requireChat().stream(messages.map(({ role, content }) => ({ role, content })), toSendOptions(chatOptions));
|
|
319
|
+
for await (const delta of iterator) {
|
|
320
|
+
chatOptions?.onProgress?.(delta);
|
|
321
|
+
yield delta;
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
subscribe(listener) {
|
|
325
|
+
listeners.add(listener);
|
|
326
|
+
return () => {
|
|
327
|
+
listeners.delete(listener);
|
|
328
|
+
};
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|