@happyvertical/smrt-svelte 0.51.11 → 0.51.12

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
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
+ }