nyaaorick commited on
Commit
1944112
·
verified ·
1 Parent(s): ac74ae7

feat: publish everything-webgpu package, engine source and documentation

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
AI.md ADDED
@@ -0,0 +1,940 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Everything WebGPU — project harness
2
+
3
+ ## Project Vision
4
+ An embeddable local-LLM engine for the browser: WebLLM on WebGPU, plus a priority scheduler, multi-step
5
+ decoding and compute-pass batching that together take decode from 9.7 to 25.9 tok/s. `main` is the library —
6
+ host-neutral, no UI, usable from a page, a worker or an extension. `demo` keeps the Firefox WebExtension
7
+ that was the original vehicle and is now the library's first consumer. See [ARCHIVE.md](ARCHIVE.md).
8
+
9
+ Weights arrive by whichever route suits the app: WebLLM's 163 prebuilt models from HuggingFace, any base URL
10
+ the developer hosts, or a local folder read off disk with no network connection at any point.
11
+
12
+ **Model: `empero-ai/Qwen3.8-2B-Distill`, compiled to MLC in-house — done and running at 16.6-18.1 tok/s.** The original "4B+" goal was set before decode was instrumented; the measurements retired it. Decode is memory-bandwidth-bound, so time per token scales with weight bytes: a 4B at `q4f16_1` is ~2.25 GB and projects to 7-9 tok/s with only one engine fitting in 16 GB, while this 2B is 1.06 GB and was projected at 14-18 tok/s — the measurement landed inside that band. Build notes and the toolchain fixes are in [MLC-COMPILE.md](MLC-COMPILE.md).
13
+
14
+ ## Workflow & Development Principles
15
+ - **Fail Fast**: Validate inputs, model states, and cache availability early. Throw descriptive errors immediately upon invalid conditions.
16
+ - **Minimal Surface**: Write only necessary code and tests. Prefer a single, comprehensive integration test over redundant unit tests.
17
+ - **Reuse First**: Leverage existing internal APIs, built-in libraries, and ecosystem patterns (e.g., WebLLM, Cache API) before introducing net-new abstractions.
18
+ - **Direct Execution**: Output exact code changes or direct answers. Omit preamble, pleasantries, conversational fillers, and unsolicited caveats.
19
+
20
+ ## Where the work lives
21
+
22
+ | | |
23
+ | --- | --- |
24
+ | [README.md](README.md) | The developer-facing entry point. Asserted by [test/readme.test.mjs](test/readme.test.mjs), so its examples cannot drift from the API. |
25
+ | [API.md](API.md) | Every call form, one page. Asserted by [test/api-doc.test.mjs](test/api-doc.test.mjs) — method names, error codes, enum values and the export list are all derived from the source. |
26
+ | [ROADMAP.md](ROADMAP.md) | **The only list of open work.** |
27
+ | [ARCHIVE.md](ARCHIVE.md) | What was done and *why* — decisions with their reasoning, so they are not re-litigated. |
28
+ | [WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) | What WebLLM already does. **Read before adding a capability**, and follow its "Upgrading" runbook on every dependency bump. |
29
+ | This file | What is true and measured. Reference, not a plan. |
30
+
31
+ Task lists used to live here, in AI2.md and in NATIVE-REUSE-PLAN.md at the same time, with "Track 1"
32
+ and "Track 2" meaning different things in each. That is how a reader implements the wrong item, and
33
+ it is the same shape of failure — no single place to look before acting — that produced the
34
+ duplicated WebLLM helpers ARCHIVE.md records.
35
+
36
+ ## Completed Tasks
37
+
38
+ Through the in-house model compile. Everything after that — the library extraction, the model
39
+ sources, the WebLLM de-duplication — is in [ARCHIVE.md](ARCHIVE.md) with its reasoning.
40
+
41
+ - [x] Defined core requirements for local WebGPU-based execution in Firefox on macOS.
42
+ - [x] Established direct cache-injection architecture for offline local models.
43
+ - [x] Configured `manifest.json` (MV2, persistent background page, `wasm-unsafe-eval` CSP, `unlimitedStorage`) and documented the `about:config` flags in the manager page.
44
+ - [x] Implemented drag-and-drop model folder ingestion that writes straight into `Cache Storage` under WebLLM's own scopes and keys.
45
+ - [x] Built the minimal test chat popup driven by the background WebLLM engine.
46
+ - [x] Exposed the `everything-webgpu/v1` message + port API for other Firefox extensions.
47
+ - [x] Verified end-to-end on real hardware with `Qwen3.5-0.8B-q4f16_1-MLC` (Firefox 154, macOS, M4).
48
+ - [x] Root-caused decode throughput to Firefox's 100 ms WebGPU poll timer (Mozilla bug 1870699), not to anything in this extension.
49
+ - [x] Replaced the fail-fast `busy` flag with a priority scheduler: bands, session supersession, opt-in preemption.
50
+ - [x] Added a worker-backed engine pool and a `batch` API so independent work runs concurrently.
51
+ - [x] Implemented multi-step decoding (vLLM's `--num-scheduler-steps`, default 15) so one GPU sync covers K tokens: 9.7 -> 18.4 tok/s single-stream.
52
+ - [x] Instrumented decode with a CPU-encode / GPU-execute probe and root-caused the remaining ~46 ms/token to 664 per-token kernel launches, each in its own WebGPU compute pass — not the poll tick, not command encoding.
53
+ - [x] Batched consecutive tvmjs kernel launches into one compute pass (664 passes/token -> ~16): 10.3 -> 25.9 tok/s on an identical greedy generation, byte-identical output.
54
+ - [x] Priced the remaining gap against the platform: ~50 GB/s is the most a hand-written WGSL kernel gets here, ~16 GB/s is what the generated dequant-GEMV gets, and the difference is the reduction tail amortised over a 2-iteration loop — not load width (~1.15x) and not dequantisation (~free).
55
+ - [x] Compiled `Qwen3.8-2B-Distill` to MLC/WebGPU in-house: vision tower and MTP head stripped, `q4f16_1`, 1.06 GB, 26 shards. Five separate breakages in the published MLC nightlies had to be worked around first — see [MLC-COMPILE.md](MLC-COMPILE.md) and `tools/`.
56
+ - [x] Made the engine pool demand-driven: one engine at load, a second only when a second task competes, one engine per task. Measured that a second engine is worth 1.06x on this model, so it buys isolation rather than throughput.
57
+ - [x] Surfaced the model-load report in the UI. WebLLM's text was always being passed through, but `#status` was a single ellipsised line, so it was truncated to `Loading model from cache[26/5…`.
58
+
59
+ ## Consolidated Context
60
+ - **Target Platform**: Firefox WebExtension (macOS, requiring WebGPU flags). Test machine: M4 MacBook Air, 16 GB unified memory, ~120 GB/s.
61
+ - **Core Stack**: JavaScript, WebGPU, WebLLM (`@mlc-ai/web-llm` 0.2.84, patched at build time), Cache API (for local file injection), Extension Message Passing.
62
+ - **Architecture**: A host-neutral engine (`src/engine/`, asserted free of any WebExtension API) behind
63
+ per-host adapters (`src/adapters/`). The Firefox extension is one host: background engine host + popup test
64
+ UI + extension-to-extension API provider. See [ARCHIVE.md](ARCHIVE.md).
65
+ - **Model**: `Qwen3.8-2B-q4f16_1` (1.06 GB), compiled in-house from `empero-ai/Qwen3.8-2B-Distill` — see [MLC-COMPILE.md](MLC-COMPILE.md). `Qwen3.5-0.8B-q4f16_1-MLC` remains the baseline most of the analysis below was measured on. Keep `q4f16_1` — dequantisation measured ~free, so wider formats only add bytes, and bytes are what decode pays for.
66
+ - **Scheduling**: one shared GPU, one engine per task, pool grows on demand. A second engine measured 1.06x on this model, so it buys isolation rather than throughput.
67
+ - **Build-time patches** ([build/patches.mjs](build/patches.mjs), applied by [build.mjs](build.mjs)): `storage-buffer-limit` (Firefox caps storage buffers per stage at 9, tvmjs asks for 10) and `compute-pass-batching` (one compute pass per kernel launch -> one per flush). Anchors are literal JS matched modulo whitespace, word-bounded, and optionally scoped to an enclosing function; all are verified before anything is rewritten, so a WebLLM bump reports every break at once with the nearest candidate lines. `npm run verify-patches` checks them without rebuilding; `NO_PASS_MERGE=1` skips the second patch for A/B.
68
+
69
+ ---
70
+
71
+ ## Verified
72
+
73
+ All numbers on an M4 MacBook Air (16 GB), Firefox 154 release, macOS.
74
+
75
+ ### The shipping model: `Qwen3.8-2B-q4f16_1` (1.06 GB, 26 shards)
76
+
77
+ Compiled in-house; see [MLC-COMPILE.md](MLC-COMPILE.md).
78
+
79
+ | | |
80
+ | --- | --- |
81
+ | Ingest 1.06 GB into Cache Storage | 4.8 s |
82
+ | Model load (cache only, zero network) | 51 s |
83
+ | Prefill | 48 tok/s short prompt, 100-200 tok/s at length |
84
+ | **Decode** | **16.6-18.1 tok/s** |
85
+ | Re-prefill cost per history token | 5.27 ms (no cross-turn KV reuse — see Current Tasks) |
86
+ | Kernel launches per decoded token | 664 = 639 forward + 25 sampling, across ~16 flushes |
87
+ | Second engine, 4-prompt batch | **1.06x** — see "Scheduling" |
88
+ | Two tasks on two engines (e2e) | 1.05x, then **1.11x and 1.12x** on two later runs. The ratio held across runs whose absolute times differed by 35%, so it is a real shift rather than noise — but 1.11x is still isolation, not throughput scaling. |
89
+
90
+ ### The baseline it was built against: `Qwen3.5-0.8B-q4f16_1` (443 MB, 11 shards)
91
+
92
+ Everything below this point was measured on the 0.8B. It is kept because it is where the
93
+ architecture came from — the ceiling, the multi-step fix and the pass-batching fix were all found here.
94
+
95
+ | | |
96
+ | --- | --- |
97
+ | WebGPU in the MV2 background page | available, `shader-f16` supported |
98
+ | Ingest 443 MB into Cache Storage | 2.1 s |
99
+ | Model load (cache only, zero network) | 48 s |
100
+ | Prefill | 95-98 tok/s |
101
+ | Decode, stock single-step | 9.6 tok/s (warm) |
102
+ | Decode, multi-step K=15, one pass per kernel | 17.3-18.3 tok/s |
103
+ | Decode, multi-step K=15 + batched compute passes | **24.9-28.0 tok/s** (25.9 when first measured; 27.4, 28.0, 24.9 across later e2e runs on the same build — run-to-run spread is ~12%, so treat any single number as ±1.5) |
104
+ | Decode, K=32 + batched passes | 26.8 tok/s |
105
+ | Kernel launches per decoded token | 664, across ~16 flushes |
106
+ | Decode budget at K=16 | 3.8 ms CPU encode + ~34 ms GPU + <6 ms tick |
107
+
108
+ Reproduce with `npm run e2e`, or `ENGINE_COUNT=1 DECODE_STEPS=1,2,4,8,16,24 npm run e2e` for the multi-step
109
+ curve — see [test/e2e/run.mjs](test/e2e/run.mjs).
110
+
111
+ Three things that surfaced from running it for real:
112
+
113
+ - **WebGPU works in the background page.** This was the load-bearing assumption behind putting the engine
114
+ there, and it holds on release Firefox.
115
+ - **Neither the poll tick nor CPU encoding is the ceiling.** Both measured small once instrumented. What was
116
+ left split in two: 664 per-kernel compute passes (fixed, ~2.5x) and, underneath, decode running at ~13% of
117
+ memory bandwidth (open). See "Where the 46 ms goes" and "Still not 100+ tok/s".
118
+ - **Firefox needed a shim.** tvmjs hardcodes a request for 10 storage buffers per shader stage; Firefox's
119
+ Metal backend caps `maxStorageBuffersPerShaderStage` at 9, so `detectGPUDevice()` threw before a device was
120
+ ever requested. `build.mjs` clamps that request to what the adapter reports, and fails the build loudly if
121
+ the patch stops matching after a WebLLM upgrade. Kernels that genuinely need a 10th binding still fail at
122
+ pipeline creation — **silently**, as a no-op dispatch that emits garbage rather than an error. Both models
123
+ ship four such kernels; three are unreachable by config and the fourth, `batch_prefill_paged_kv_kernel`, is
124
+ kept off the live path by `engine-worker.js` calling `resetChat()` before every prefill when the device
125
+ reports fewer than 10. `node tools/audit-wasm.mjs <folder>` checks this.
126
+
127
+ ### The 10 tok/s ceiling
128
+
129
+ Decode is capped by Firefox, not by this extension or by the GPU. [`WebGPUParent`](https://searchfox.org/firefox-main/source/dom/webgpu/ipc/WebGPUParent.cpp)
130
+ detects GPU completion by polling on a fixed timer:
131
+
132
+ ```cpp
133
+ const uint64_t POLL_TIME_MS = 100;
134
+ mTimer.Start(base::TimeDelta::FromMilliseconds(POLL_TIME_MS), this, &WebGPUParent::MaintainDevices);
135
+ ```
136
+
137
+ So every `onSubmittedWorkDone()` / `mapAsync()` resolves only on a 100 ms tick. WebLLM needs exactly one
138
+ GPU->CPU sync per token (it reads back the 4-byte sampled token id before it can compute the next one), and a
139
+ serial sync loop always lands just after a tick and waits the full period. One token per tick = 9.6 tok/s.
140
+
141
+ `npm run bench` (no extension, no model, plain page) measures this directly:
142
+
143
+ | measurement | result | meaning |
144
+ | --- | --- | --- |
145
+ | `idleSyncMs` | 104 ms | awaiting an **empty** queue with nothing submitted still costs a full tick |
146
+ | `dispatch256Ms` | 104 ms | 256 dispatches **sharing one compute pass and one bind group** cost the same as zero |
147
+ | `pipelined10SubmitsMs` | 104 ms | 10 submits, one await — it is latency, not throughput |
148
+ | `syncAfterRandomDelay` | mean 52 ms, min 3, max 106 | desynchronize from the tick and you get the expected uniform 0-100 ms |
149
+ | `n2048` | `1pass=104ms 2048passes=309ms` | a compute *pass* costs ~100 µs |
150
+ | `dispatch65536InOnePassMs` | 207 ms | a *dispatch* inside a pass costs ~2 µs — measured past the tick, not bounded by it |
151
+
152
+ Read the `dispatch256Ms` row carefully — reusing one pass and one bind group is **not** what the runtime does,
153
+ and reading it as "compute is free" is what produced the wrong conclusion corrected below.
154
+
155
+ WebLLM's own `enable_latency_breakdown` agrees: `totalTime=101.2ms`, of which `sampleTime=101.1ms` — the step
156
+ containing the sync is the entire token budget. Identical numbers in a hidden background page, a visible tab,
157
+ and an ordinary web page, with and without `MOZ_DISABLE_GPU_PROCESS=1`, so it is not extension-specific and
158
+ not GPU-process IPC.
159
+
160
+ > **Correction.** This section used to claim actual compute was "~7 ms/token (≈137 tok/s if the sync were
161
+ > free)". That was wrong, and it was wrong in a way that mattered: the 7 ms came from `dispatch256Ms -
162
+ > dispatch1Ms`, a micro-benchmark of trivial kernels sharing one compute pass and one bind group — nothing
163
+ > like a real forward pass. Removing the per-token sync (see "Multi-step decoding") took decode to 18.4 tok/s,
164
+ > not 137, because real per-token GPU work is ~46 ms. See "Where the 46 ms goes".
165
+
166
+ This is [Mozilla bug 1870699, "Don't poll WebGPU from a timer"](https://bugzilla.mozilla.org/show_bug.cgi?id=1870699),
167
+ still open on trunk. Event-driven completion in Firefox would fix it outright. Short of that, the lever is to
168
+ stop needing a sync per token — see "Multi-step decoding" below, which does exactly that and lifts a single
169
+ stream well past 10 tok/s.
170
+
171
+ **Practical consequence:** the tick sets a *floor* of ~10 tok/s on serial decode, and multi-step lifts a
172
+ single stream off it — but only to ~18 tok/s, where a second, larger cost takes over. Prefill is unaffected
173
+ (one sync per forward pass regardless of token count), so prompt processing, embeddings and short completions
174
+ were never the problem.
175
+
176
+ ### Multi-step decoding
177
+
178
+ The ceiling above is a *sync* budget, so the fix is to spend fewer syncs: run K forward steps per sync. This
179
+ is vLLM's [`--num-scheduler-steps`](https://blog.vllm.ai/2024/09/05/perf-update.html) (v0.6.0, +28% on
180
+ Llama-70B / 4xH100), and it lands here without recompiling any model, because WebLLM's sampling path is
181
+ already entirely on the GPU. `softmax_with_temperature` -> `argsort_probs` -> `sample_with_top_p` returns an
182
+ int32[1] **device** tensor, and `Tensor.copyFrom(Tensor)` is a device-to-device copy — so the sampled id can
183
+ be fed straight back into `embed` without ever becoming a JS number:
184
+
185
+ ```
186
+ embed -> decode -> penalties -> softmax -> argsort -> sample ─┐ x K, no sync
187
+ ^ │
188
+ └──��───────────── device tensor, never read back ───────────┘
189
+ then: one device.sync()
190
+ ```
191
+
192
+ Each step stages its id into its own CPU tensor; tvmjs queues those readbacks into `pendingGPUToCPUCopy` and
193
+ only awaits them in `sync()`, so K readbacks still cost one tick. `steps` defaults to **15**, vLLM's documented
194
+ cap. [src/background/multistep.js](src/background/multistep.js) is the whole implementation; it lives in the
195
+ engine worker because that is where the decode loop is.
196
+
197
+ **Measured, single engine, Qwen3.5-0.8B on an M4 Air.** `DECODE_STEPS=... npm run e2e` sweeps K live on one
198
+ loaded model and reports the probe built into the burst — the K-step loop contains no `await`, so timing
199
+ either side of its single sync partitions the budget exactly:
200
+
201
+ | K | tok/s | cpu-encode | gpu + tick | kernels/tok |
202
+ | --- | --- | --- | --- | --- |
203
+ | 1 (stock) | 9.7 | — | — | — |
204
+ | 2 | 11.5 | 4.3 ms | 82.1 ms | 664 |
205
+ | 4 | 16.0 | 4.8 ms | 57.6 ms | 664 |
206
+ | 8 | 16.8 | 4.5 ms | 54.8 ms | 664 |
207
+ | 16 | 17.8 | 4.2 ms | 52.1 ms | 664 |
208
+ | 24 | **18.4** | 4.2 ms | 50.1 ms | 664 |
209
+
210
+ The curve is smooth and monotone, saturating near 18-19 tok/s: **+90% over stock at the shipped default of
211
+ 15**. There is no sawtooth — an earlier version of this section predicted one from tick quantization, which
212
+ only appears when per-step compute is small next to the 100 ms tick. It is not: it is ~46 ms. Past K≈16 the
213
+ tick is already amortized to under 6 ms/token and there is nothing left to win.
214
+
215
+ ### Where the 46 ms goes
216
+
217
+ Subtracting the amortized tick from the `gpu + tick` column leaves ~46 ms/token of real GPU time, against a
218
+ memory-bandwidth floor of ~3.5 ms (420 MB of q4f16 weights read once per token at ~120 GB/s). So ~93% of
219
+ decode is overhead, and the probes say which:
220
+
221
+ | candidate | measured | verdict |
222
+ | --- | --- | --- |
223
+ | the 100 ms poll tick | <6 ms/token at K≥16 | amortized away by multi-step |
224
+ | CPU command encoding, `createBindGroup`, IPC | 4.2 ms/token, flat in K | **not** the bottleneck |
225
+ | GPU execution | ~46 ms/token over **664 kernel launches** | this is it, at ~70 µs/kernel |
226
+
227
+ 664 kernels per token is ~24 per layer — an unfused graph — and tvmjs opens a **separate compute pass for
228
+ every one of them** (`submitShader`: `beginComputePass` → `setPipeline` → `createBindGroup` → `dispatch` →
229
+ `end`). WebGPU barriers between passes, and on Metal each pass is its own command encoder. `npm run bench`
230
+ isolates the cost:
231
+
232
+ | measurement | result |
233
+ | --- | --- |
234
+ | 2048 dispatches, **one** compute pass | 104 ms (i.e. free — it is all tick) |
235
+ | 2048 dispatches, **2048** compute passes | 309 ms → ~100 µs per pass |
236
+ | CPU cost of encoding one tvm-style kernel | 3.9 µs |
237
+
238
+ So the pass is ~25x its own encode cost, and it is charged 664 times per token — while only **16 flushes per
239
+ token** actually happen, i.e. ~41 consecutive launches share an encoder and each open their own pass for no
240
+ reason. Dispatches *within* one WebGPU compute pass are already ordered with implicit barriers (a compute
241
+ pass has a usage scope per dispatch, so implementations must synchronize between them), so those passes were
242
+ not buying correctness.
243
+
244
+ ### Batching the compute passes
245
+
246
+ The `compute-pass-batching` patch in [build/patches.mjs](build/patches.mjs) rewrites tvmjs to open a pass
247
+ lazily and close it in `flushCommands()` — already the one chokepoint every operation that cannot run
248
+ mid-pass routes through. That turns 664 passes/token into ~16. Three edits, each refusing to apply if its
249
+ anchor stops matching after a WebLLM upgrade, same as the storage-buffer shim next to it. The `compute.end();`
250
+ anchor is scoped to the function the pass is opened in, so an unrelated compute pass elsewhere in tvmjs is not
251
+ mistaken for ambiguity. Build with `NO_PASS_MERGE=1` to skip it and A/B on one machine.
252
+
253
+ Measured on the same 127-token greedy generation (`temperature: 0`, single engine, K=15):
254
+
255
+ | | stock WebLLM | + multi-step | + pass batching |
256
+ | --- | --- | --- | --- |
257
+ | decode | 9.7 tok/s | 10.3 tok/s | **25.9 tok/s** |
258
+ | `gpu + tick` | — | 85.2 ms/tok | **33.8 ms/tok** |
259
+ | `cpu-encode` | — | 5.1 ms/tok | 4.4 ms/tok |
260
+ | kernels/token | 664 | 664 | 664 |
261
+ | compute passes/token | 664 | 664 | **~16** |
262
+
263
+ Output is **byte-identical** across the A/B (490 bytes, greedy) — the check that matters, since the whole
264
+ change rests on WebGPU synchronizing dispatches inside a pass.
265
+
266
+ One caveat on the numbers: the unbatched build is *noisy* run to run (K=16 measured anywhere from 10.7 to
267
+ 18.6 tok/s, `gpu + tick` from 49.6 to 92.1 ms), while the batched build is tight (24.6-26.8 tok/s, 33.4-37.3
268
+ ms across five runs). The table above is a same-session pair, and its baseline happened to land on the slow
269
+ side. Against the *best* baseline seen the gain is ~1.4x rather than 2.5x; against the median, ~1.8x. The
270
+ direction and the mechanism are not in doubt, but quote a range, not the 2.5x.
271
+
272
+ The K curve keeps the same shape, shifted up, and still has no sawtooth: 2 → 19.3, 8 → 23.8, 16 → 25.6,
273
+ 32 → 26.8 tok/s. `DEFAULT_DECODE_STEPS` stays at 15 (25.6 tok/s); K=32 buys another 4.7% for double the
274
+ transient buffers and more lookahead discarded at every stop token. K=1 is unchanged at 9.7 — with GPU time
275
+ now ~34 ms it fits inside a tick, so single-step is purely tick-bound and only multi-step can help it.
276
+
277
+ ### Still not 100+ tok/s, and it is not the launch count
278
+
279
+ ~34 ms/token of GPU remains. It is tempting to blame the 664 launches, and an earlier version of this file
280
+ did. That is wrong, and the bench says so once it is pushed past the tick:
281
+
282
+ | dispatches in **one** pass | wall | per dispatch |
283
+ | --- | --- | --- |
284
+ | 2 048 | 104 ms | ≤50.8 µs — but this *is* the tick, so it only bounds the number |
285
+ | 16 384 | 102 ms | ≤6.2 µs — still one tick |
286
+ | 65 536 | 207 ms | ~2.1 µs marginal |
287
+
288
+ A dispatch inside a pass costs **~2 µs**, so all 664 of them cost ~1.3 ms, and the ~16 remaining passes add
289
+ ~1.6 ms. Neither is the 34 ms. Note how the first row alone would have supported the wrong conclusion: any
290
+ measurement that lands on 104 ms is the poll grid talking, not the GPU.
291
+
292
+ What is left is the kernels' own execution. Splitting the launches at the forward/sample boundary gives
293
+ **639 forward + 25 sampling** — the 248k-vocab `argsort_probs` is only 4% of the budget, so the sampling tail
294
+ is not it either. Subtracting the amortized tick leaves ~27 ms of forward execution to move 420 MB of q4f16
295
+ weights, i.e. **~16 GB/s against the M4's ~120 GB/s — about 13% of peak**.
296
+
297
+ That is the signature of batch-size-1 decode: a matrix-*vector* product where every weight is read once, used
298
+ for a single multiply-accumulate, and thrown away, with 4-bit dequantization on top. Intermediates are not
299
+ the problem (hidden size 1024 means a ~2 KB activation, negligible next to the weights). So the lever is
300
+ *faster* kernels, not fewer: better dequant-GEMV, or a quantization whose kernels reach a higher fraction of
301
+ peak. See "Does a different model help?".
302
+
303
+ ### Why not llama.cpp/Ollama-class, and what would close it
304
+
305
+ Decode moves 420 MB of weights per token, so every question about throughput is a question about achieved
306
+ memory bandwidth. `npm run bench` measures the two ceilings above us — a hand-written WGSL kernel that does
307
+ nothing but stream a 512 MB buffer, swept over workgroup counts so an untuned kernel is not mistaken for a
308
+ platform limit:
309
+
310
+ | layer | achieved | of hardware peak |
311
+ | --- | --- | --- |
312
+ | M4 (128-bit LPDDR5X) theoretical | ~120 GB/s | 100% |
313
+ | Best hand-written WebGPU streaming read, Firefox | **~50 GB/s** | ~43% |
314
+ | MLC/TVM dequant-GEMV during decode | **~16 GB/s** | ~13% |
315
+
316
+ Two independent gaps, with different owners:
317
+
318
+ - **120 → 50 is the platform.** Flat at 50-53 GB/s across 256/1024/4096/16384 workgroups, so it is not an
319
+ occupancy or parallelism problem in the probe. Nothing in this repo moves it.
320
+ - **50 → 16 is the kernels.** This is the one worth attacking: ~3x, and it is entirely inside the generated
321
+ dequant-GEMV.
322
+
323
+ #### Where the 50 -> 16 goes, measured
324
+
325
+ The generated kernel is in the model's `.wasm` as WGSL, so it can be read directly. The hot one
326
+ (`fused_dequantize2_fused_NT_matmul1_silu1_multiply1_kernel`) is `@workgroup_size(64)`, each thread runs a
327
+ **2-iteration** loop over packed weights, and the workgroup then reduces through `var<workgroup> red_buf0`
328
+ with barriers.
329
+
330
+ `npm run bench` rebuilds that kernel one property at a time against the same 512 MB buffer, so each step's
331
+ cost is isolated:
332
+
333
+ | kernel property | GB/s | cost |
334
+ | --- | --- | --- |
335
+ | `vec4` loads, 256-wide, long loop — the ceiling | ~50 | — |
336
+ | scalar `u32` loads (what packed 4-bit weights force) | ~41-46 | ~1.15x |
337
+ | + unpack 8 nibbles, centre, scale | ~41 | ~1.0x — **dequant arithmetic is nearly free** |
338
+ | + 64-lane barrier reduction, 32 iters/thread | 34.0 | 1.2x |
339
+ | + 8 iters/thread | 27.0 | 1.5x |
340
+ | **+ 2 iters/thread — what MLC actually generated** | **18.6** | **2.2x** |
341
+ | decode in the real engine (adds scale traffic, writes, real grid) | ~16 | 2.6x |
342
+
343
+ So neither load width nor dequantisation is the problem. **The cost is the reduction tail amortised over
344
+ almost no work**: each workgroup reads 128 `u32` — 512 bytes — and then spends six `workgroupBarrier()`s
345
+ doing a 64-lane tree reduction. Raising work per thread from 2 to 32 iterations recovers **1.83x** on its own
346
+ (18.6 -> 34.0 GB/s) with everything else held fixed.
347
+
348
+ That is a *schedule* parameter, chosen by TVM's dlight GEMV rule at model-compile time and baked into the
349
+ `.wasm`. It is not something the runtime can change: the launch grid is emitted by the host side of the same
350
+ module, so substituting a shader would desynchronise it from its dispatch shape. The fix is to recompile the
351
+ model lib — same weights, retuned GEMV tiling — and it is also exactly what subgroups would make moot, since
352
+ `simd_sum` reduces 32 lanes without a barrier at all.
353
+
354
+ Caveat: these are faithful *models* of the kernel, not the kernel itself; the real one also streams the scale
355
+ array and writes results, which is roughly where the last 18.6 -> 16 goes.
356
+
357
+ **What is structurally different in llama.cpp's pipeline** (reasoned from the two codebases, *not* measured
358
+ here — Ollama is not installed on the test machine):
359
+
360
+ 1. **Hand-written Metal vs WGSL→naga→MSL.** WebGPU mandates bounds-checked buffer access; naga emits clamps
361
+ on dynamic indexing, which lands in a GEMV's innermost loop.
362
+ 2. **No subgroup ops.** llama.cpp's Metal GEMV leans on `simd_sum`/`simd_shuffle` for cross-lane reduction
363
+ and `simdgroup_matrix` for matmul. WGSL subgroups are not exposed in Firefox, so TVM must reduce through
364
+ workgroup shared memory and barriers — the expensive path for a reduction-shaped kernel.
365
+ 3. **Quantization co-designed with its kernel.** `Q4_K` and friends are laid out so a block dequantizes in
366
+ registers from vectorized loads; `q4f16_1` goes through a generated TVM dequant instead.
367
+
368
+ **Is "80% of Ollama" reachable here?** Probably not on Firefox WebGPU today, and the arithmetic says why
369
+ rather than the vibes. Even if the dequant-GEMV became as efficient as a *pure streaming read* — which it
370
+ cannot be, since it also has to dequantize and reduce — 420 MB at 50 GB/s is 8.4 ms/token, about **110-120
371
+ tok/s ceiling** for this model on this platform. Any Ollama number above ~140 tok/s therefore puts 80% of it
372
+ out of reach no matter how good the kernels get, because the 120 → 50 gap is not ours to close.
373
+
374
+ So the honest targets are: **~70 tok/s is plausible** (halve the 50 → 16 kernel gap), ~110 tok/s is the
375
+ absolute platform ceiling, and matching a fast native runtime is not on the table without subgroups landing
376
+ in Firefox or a move off WebGPU. Measure the actual Ollama figure on the same machine before committing to a
377
+ percentage target — it is the one number in this section that is assumed rather than measured.
378
+
379
+ > Also worth knowing: the bench loses the WebGPU device outright when it creates 2048 compute passes in one
380
+ > encoder (`deviceLostDuringBench = yes, during pass-sweep`), and a lost device does not throw — later calls
381
+ > silently no-op and report impossible numbers like 1374 GB/s. The unpatched runtime issued 664 passes per
382
+ > token, which is uncomfortably close to that. The dispatch cap in `patchComputePassBatching()` exists for
383
+ > this reason.
384
+
385
+ ### Does a different model help?
386
+
387
+ Decode cost per token is roughly `weight bytes / effective bandwidth + ~1.3 ms of launches + tick/K`.
388
+ The measured effective bandwidth is ~16 GB/s of the M4's ~120 GB/s, so the first term dominates and scales
389
+ with parameter count and quantization — not with how the layers are arranged. Extrapolating from the measured
390
+ 26 tok/s at 420 MB:
391
+
392
+ | change | weight bytes | projected | why |
393
+ | --- | --- | --- | --- |
394
+ | another 0.8B architecture, same quant | ~450 MB | ~26 tok/s | same traffic per token; layer layout is not the variable |
395
+ | **Qwen3.8-2B-Distill** (shipped) | 1.06 GB | **16.6-18.1 tok/s, measured** | projected 14-18; the projection held |
396
+ | Llama-3.2-3B | ~1.7 GB | ~9 tok/s | prebuilt MLC folder exists, so no toolchain — the cheap way to sanity-check these projections |
397
+ | a 4B at q4f16 | ~2.25 GB | ~7-9 tok/s | 5x the bytes, and only `engineCount=1` fits in 16 GB. Even at *100%* of peak it is ~19 ms/token = 53 tok/s |
398
+ | a ~0.3B at q4f16 | ~160 MB | ~60-70 tok/s | helps, but buys less than fixing the efficiency |
399
+ | **same weights, different quantization** | varies | see below | resolved: it changes byte count and nothing else |
400
+
401
+ Every row except the 2B is still a projection from the measured 16.8 GB/s. The 2B row is now a measurement,
402
+ and it landed inside its projected band — which is the only evidence available that this model of decode cost
403
+ predicts anything.
404
+
405
+ So: switching models does not get to 100+ tok/s, and going bigger costs throughput roughly in proportion to
406
+ the extra bytes. This table is why the "4B+" goal was retired in favour of a 2B — see "Project Vision".
407
+ 100 tok/s means ~10 ms/token, which at today's 16 GB/s buys only ~160 MB of weights, but at full bandwidth
408
+ would buy ~1.2 GB. **Closing the efficiency gap is worth ~7x more than shrinking the model.**
409
+
410
+ **Which quantization to recompile with: keep `q4f16_1`.** That experiment is settled by the kernel breakdown
411
+ above — unpacking eight nibbles, centring and scaling them measured ~free (41 GB/s with the dequant
412
+ arithmetic vs 41-46 GB/s without). Since dequantisation costs nothing and bytes-per-token is what decides
413
+ throughput, a wider format is pure loss:
414
+
415
+ | format | bytes per 32 weights | vs q4f16_1 | verdict |
416
+ | --- | --- | --- | --- |
417
+ | `q4f16_1` (current) | 16 + 2 scale = 18 | 1.00x | keep |
418
+ | `q3f16_1` | 12 + 2 = 14 | 0.78x -> ~1.28x faster | the only quantization that buys speed, and it costs accuracy |
419
+ | `q4f32_1` | 16 + 4 = 20 | 1.11x slower | f32 scales and activations, and f16 ALU is faster on Apple |
420
+ | `q0f16` | 64 + 0 = 64 | 3.6x slower | skips a dequant that was already free |
421
+ | `q4f16_awq` | 18 | 1.00x | same speed, better accuracy — worth it for quality, not for speed |
422
+
423
+ So the ~1.28x from `q3f16_1` is both smaller than the 1.83x available from the schedule *and* the only one of
424
+ the two that trades away accuracy. Fix the schedule first; treat quantization as a quality knob.
425
+
426
+ One structural note specific to these weights: `vocab_size` is 248320 with `tie_word_embeddings`, so the
427
+ output projection alone is 1024 x 248320 = 254 M parameters, about **30% of every token's memory traffic**.
428
+ That is unusually large (most models are 32k-128k) and no quantization choice changes its share.
429
+
430
+ Set `decodeSteps: 1` to turn multi-step off; `NO_PASS_MERGE=1 npm run build` to turn pass batching off.
431
+
432
+ ## Build and install
433
+
434
+ ```sh
435
+ npm install
436
+ npm run build # bundles @mlc-ai/web-llm into vendor/web-llm.js
437
+ npm test # integration test over the cache-injection contract
438
+ npm run e2e # real Firefox + real model + real GPU (needs a model folder)
439
+ npm run bench # WebGPU submit/sync latency only - no extension, no model
440
+ npm run package # -> everything-webgpu.xpi
441
+ ```
442
+
443
+ Env vars that matter when measuring rather than just running:
444
+
445
+ | Var | Applies to | Effect |
446
+ | --- | --- | --- |
447
+ | `NO_PASS_MERGE=1` | `npm run build` | Skip compute-pass batching, for an A/B on one machine. |
448
+ | `ENGINE_COUNT=n` | `npm run e2e` | Force the pool size; use `1` to measure a single stream. |
449
+ | `DECODE_STEPS=a,b,c` | `npm run e2e` | Sweep multi-step widths live on one loaded model. |
450
+ | `SKIP_BENCH=1` | `npm run e2e` | Drop the two ~40 s `gpuBench` passes when comparing builds. |
451
+ | `E2E_VERBOSE=1` | `npm run e2e` | Surface web-ext/Firefox output; without it a failed launch and a hung extension look the same. |
452
+ | `MODEL_DIR=…` | `npm run e2e` | Model folder (defaults to `~/Downloads/Qwen3.5-0.8B-q4f16_1-MLC`). |
453
+
454
+ Load it with `about:debugging` → This Firefox → Load Temporary Add-on → pick `manifest.json`.
455
+
456
+ Before a model can load, set these in `about:config` and restart Firefox:
457
+
458
+ | Pref | Value | Why |
459
+ | --- | --- | --- |
460
+ | `dom.webgpu.enabled` | `true` | Exposes `navigator.gpu`. |
461
+ | `gfx.webgpu.ignore-blocklist` | `true` | Only if your Mac's GPU is blocklisted. |
462
+ | `dom.webgpu.service-workers.enabled` | `true` | Harmless; needed on builds that gate non-visible contexts. |
463
+
464
+ The manager page shows live WebGPU status, so you can tell a flag problem from a model problem.
465
+
466
+ ## Adding a model
467
+
468
+ Three routes, and `load()` resolves across all of them. Two of them are one call:
469
+
470
+ ```js
471
+ // 1. prebuilt — one of WebLLM's 163 HuggingFace models. Nothing to register.
472
+ await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
473
+
474
+ // 2. remote — any base URL you host: an HF repo, a CDN, a path on your origin, localhost.
475
+ await engine.registerModel({
476
+ modelId: "Qwen3.8-2B-q4f16_1-MLC",
477
+ model: "/models/Qwen3.8-2B-q4f16_1-MLC/",
478
+ modelLib: "/models/Qwen3.8-2B-q4f16_1-MLC/Qwen3.8-2B-q4f16_1-webgpu.wasm",
479
+ });
480
+
481
+ // 3. local — read off disk. No network connection at any point, ever.
482
+ await engine.registerModel({ modelId: "Qwen3.8-2B-q4f16_1-MLC", files });
483
+ ```
484
+
485
+ `files` is `{ path, file }[]`; `filesFromDataTransfer` and `filesFromInput` build it from a drop event or a
486
+ directory picker. `listAvailableModels()` enumerates all three; `{ prebuilt: false }` on the engine gives a
487
+ build that can never fetch a model.
488
+
489
+ In the `demo` extension this is the **Models…** page: drop a compiled MLC folder on it.
490
+
491
+ A local folder must contain:
492
+
493
+ - `mlc-chat-config.json`
494
+ - `tensor-cache.json` (or a legacy `ndarray-cache.json`)
495
+ - every `params_shard_*.bin` listed in that manifest
496
+ - `tokenizer.json` (or `tokenizer.model`)
497
+ - exactly one `*-webgpu.wasm` model library
498
+
499
+ Grab both halves from Hugging Face — the weights from `mlc-ai/<Model>-MLC`, the matching library from
500
+ `mlc-ai/binary-mlc-llm-libs` — or compile your own with `mlc_llm convert_weights` + `gen_config` + `compile`.
501
+
502
+ Local registration validates the whole folder **before** writing anything, then copies each file into Cache
503
+ Storage. A missing shard fails in milliseconds rather than after 2 GB of copying. A remote URL is not
504
+ validated at all — there is nothing to check without fetching, and WebLLM's loader reports a bad base URL
505
+ far better than a HEAD request would.
506
+
507
+ ### How local registration works
508
+
509
+ WebLLM composes every artifact URL as `new URL(relative, base)` and runs the base through `cleanModelUrl`,
510
+ which itself calls `new URL(...)` — so the base must be absolute and resolvable. A `blob:` URL cannot serve
511
+ as one, and there is no hook to hand the loader bytes directly. Pre-populating the cache under WebLLM's own
512
+ scopes and keys therefore **is** its native path: the loader does its ordinary thing and finds everything
513
+ already present.
514
+
515
+ WebLLM is never told the model is local. Each model gets a synthetic base URL
516
+ (`https://local-model.invalid/<id>/resolve/main/`) and its artifacts are written into the exact cache
517
+ scopes and keys WebLLM's loader looks up:
518
+
519
+ | Cache scope | Keys |
520
+ | --- | --- |
521
+ | `webllm/config` | `<base>mlc-chat-config.json` |
522
+ | `webllm/model` | `<base>tensor-cache.json`, tokenizer, every `params_shard_*.bin` |
523
+ | `webllm/wasm` | `<base><model>-webgpu.wasm` |
524
+
525
+ `reload()` therefore finds a full cache and issues zero requests. `.invalid` is reserved by RFC 6761 and can
526
+ never resolve — so this is the *mechanism* of the offline guarantee, not a label for it: there is no bug, no
527
+ eviction and no future refactor by which a locally-registered model reaches the network. It fails with a DNS
528
+ error instead. `test/sources.test.mjs` asserts that structurally, checking that every URL such a record
529
+ carries is on a `.invalid` host.
530
+
531
+ `ModelStore.verify()` checks every key before a load, so storage eviction is reported as "re-register the
532
+ folder" rather than a mid-load fetch. It gates **only** the local route: a remote or prebuilt model that
533
+ loses its cache just re-downloads, which is slow, not fatal.
534
+
535
+ `test/integration.test.mjs` pins this contract, including a guard that fails if a WebLLM upgrade renames a
536
+ cache scope or artifact. [test/e2e/run.mjs](test/e2e/run.mjs) proves it against a real model on a real GPU:
537
+ it temporarily wires a self-test page into the extension, drives ingest -> load -> streaming generation
538
+ through the production code paths, and restores the tree afterwards. Re-run it after bumping
539
+ `@mlc-ai/web-llm`.
540
+
541
+ ## Compiling a model in-house
542
+
543
+ Done, for [`empero-ai/Qwen3.8-2B-Distill`](https://huggingface.co/empero-ai/Qwen3.8-2B-Distill). The full
544
+ record — every command, every toolchain breakage, and the numbers — is in
545
+ [MLC-COMPILE.md](MLC-COMPILE.md); the scripts are in `tools/`. Start with:
546
+
547
+ ```sh
548
+ tools/setup-mlc-toolchain.sh # venv, patches, emsdk, tvm web runtime
549
+ ```
550
+
551
+ **Do not follow the upstream MLC quickstart.** No published pair of MLC nightlies works together, and five
552
+ separate breakages sit between `pip install` and a loadable `.wasm` — including two that produce a model
553
+ that compiles, ships and ingests cleanly and only fails at load. `setup-mlc-toolchain.sh` and
554
+ `patch-mlc-nightly.py` encode all of them and are idempotent; re-run after any `pip install`.
555
+
556
+ **Why this model.** The text architecture is the one already verified end to end — two dimensions differ and
557
+ nothing else:
558
+
559
+ | | Qwen3.5-0.8B (baseline) | Qwen3.8-2B-Distill (shipped) |
560
+ | --- | --- | --- |
561
+ | `model_type` | `qwen3_5` | `qwen3_5` |
562
+ | layers / heads / kv-heads / head_dim | 24 / 8 / 2 / 256 | 24 / 8 / 2 / 256 |
563
+ | `vocab_size`, `tie_word_embeddings` | 248320, true | 248320, true |
564
+ | `linear_*`, `full_attention_interval` | 16/128/16/128/4, every 4th | identical |
565
+ | `hidden_size` | 1024 | **2048** |
566
+ | `intermediate_size` | 3584 | **6144** |
567
+
568
+ The wider `hidden_size` also helps: the GEMV reduction is split across 64 lanes, so 1024 leaves 2 iterations
569
+ per thread while 2048 leaves 4 and the 6144 `down_proj` leaves 12 — further along the measured efficiency
570
+ curve (x2 = 18.6, x8 = 27.0, x32 = 34.0 GB/s) before any schedule retune.
571
+
572
+ **Quantization is not a tuning knob here.** Keep `q4f16_1`: unpacking eight nibbles and scaling them measured
573
+ ~free (41 GB/s with the dequant arithmetic vs 41-46 without), so a wider format only adds bytes, and bytes are
574
+ exactly what decode pays for. `q3f16_1` is the only faster option (~1.28x) and it costs accuracy — less than
575
+ the 1.83x the schedule retune offers, and unlike it, not free.
576
+
577
+ ### Verifying a build
578
+
579
+ ```sh
580
+ node tools/audit-wasm.mjs <folder> # storage buffers per kernel
581
+ node tools/wasm-imports.mjs <folder>/*.wasm <known-good>/*.wasm # runtime vs JS glue
582
+ MODEL_DIR=<folder> ENGINE_COUNT=2 npm run e2e
583
+ ```
584
+
585
+ The import check is not optional: the model library links against whatever TVM web runtime built it but runs
586
+ against whatever tvmjs `@mlc-ai/web-llm` bundles, and a mismatch fails only at load, after everything else
587
+ has passed. Read the e2e's `decode probe` line too — `kernels/tok ÷ 24 layers` still ≈27 means dlight chose
588
+ more reduction threads over more work per thread and the schedule retune did not take.
589
+
590
+ ## Scheduling
591
+
592
+ The engine is one GPU shared by every caller, so requests carry scheduling metadata and the engine — not the
593
+ caller — decides what runs when. Four mechanisms, no more ([src/background/pool.js](src/background/pool.js)):
594
+
595
+ | | |
596
+ | --- | --- |
597
+ | **Priority bands** | `interactive` > `normal` (default) > `background`, FIFO within a band. |
598
+ | **Session supersession** | A new request with the same `session` cancels the previous one. This is the ghost-text primitive: each keystroke replaces the in-flight request instead of queueing behind it. |
599
+ | **Opt-in preemption** | An `interactive` request with no free engine interrupts a running job that set `preemptible: true`. The victim resolves with its partial output and is never requeued, so nothing can starve. |
600
+ | **One task, one engine** | Every request belongs to a `task` — a whole `batch` is one task, an unlabelled `chat` is its own. A task holds at most one engine, so two runnable tasks always run side by side whenever two engines exist. |
601
+
602
+ Nothing else interrupts work in flight. A job that did not opt in always runs to completion.
603
+
604
+ ### Why one engine per task
605
+
606
+ A batch used to spread across the whole pool. On the 0.8B that was worth 1.3-2.0x, because decode was
607
+ **sync-bound** — a stream spent most of its time waiting on Firefox's ~100 ms tick, so a second stream filled
608
+ idle GPU. Multi-step decoding removed most of that wait, and on the 2B the remaining cost is real GPU work.
609
+ Measured, same four prompts, greedy so both runs emit exactly 97 tokens:
610
+
611
+ | pool | wall | aggregate | per item | peak overlap |
612
+ | --- | --- | --- | --- | --- |
613
+ | 1 | 8.2 s | 11.9 tok/s | ~2.0 s | 1 |
614
+ | 2 | 7.7 s | 12.6 tok/s | ~3.9 s | 2 |
615
+
616
+ **1.06x.** The overlap is real — busy time summed to 15.2 s against 7.7 s of wall — but each stream runs at
617
+ half speed, so they cancel. Spreading one task over the pool buys ~nothing and costs the thing a second
618
+ engine is actually for: a page translation would sit on both engines while ghost-text waited behind it.
619
+
620
+ So the rule is flat. An engine may idle while one task still has work queued; that ~6% is deliberately given
621
+ up to keep an engine free for whoever shows up next. `engine scaling:` in the e2e prints this ratio —
622
+ re-measure it per model, because on a small enough model the old fan-out logic would win again.
623
+
624
+ ### The pool grows, it is not sized
625
+
626
+ `engineCount` is a **cap, not a size**. `load()` brings up exactly one engine; the pool adds another only
627
+ when a task that owns no engine is waiting. An engine no second task ever needed is ~1.6 GB on the 0.8B and
628
+ ~2.4 GB on the 2B, bought for nothing.
629
+
630
+ **There is no budget to check first.** Firefox implements neither `navigator.deviceMemory` nor
631
+ `performance.memory`, and `navigator.storage.estimate()` reports disk quota, not RAM — verified against the
632
+ shipped binary, and re-checked every run by the `memory signals:` line in the e2e. Nothing tells an extension
633
+ how much memory is left. So the pool does not predict, it probes: **a failed load is the memory check.**
634
+ Growth then stops for that model and is not retried, and `status().growthBlocked` says so.
635
+
636
+ **Growth is not instant.** Building an engine is a full model load — measured **51 s** for the 2B — so both
637
+ tasks that triggered it will have finished first. The pool pays that once, in the background, and the second
638
+ engine is there for the *next* collision. If a workload is known to be concurrent from the start, the honest
639
+ fix is a warm-up request pair right after load, not a lower growth threshold.
640
+
641
+ ### Why each engine is a Web Worker
642
+
643
+ Several MLCEngines cannot share a realm. Running the same e2e three ways isolates it:
644
+
645
+ | setup | result |
646
+ | --- | --- |
647
+ | 1 engine, background page | passes |
648
+ | 2 engines, background page | both load, the first generates fine, the second's first generation fails: `Expected null or instance of VectorInt, got an instance of VectorInt` |
649
+ | 2 engines, one worker each | passes |
650
+
651
+ The trigger is a second engine *generating* in the same realm — not the pool, and not the engine count by
652
+ itself. That message is embind reporting a type-registry mismatch, and the bundle does carry module-scoped
653
+ emscripten state (`var Module`, `var __wasmLib`) shared by every instance, which fits; but the fix rests on
654
+ the isolation above rather than on having traced the registry.
655
+
656
+ Workers are viable because Firefox exposes WebGPU to dedicated workers and the 100 ms completion tick is
657
+ shared across them, so concurrency survives the move off the main thread (measured: 4 workers, 36.3 syncs/s).
658
+
659
+ ### How many engines are worth it
660
+
661
+ Measured on the 0.8B, four independent prompts, back when fan-out still scaled:
662
+
663
+ | pool | peak overlap | aggregate vs. serial |
664
+ | --- | --- | --- |
665
+ | 1 | 1 | 1.00x |
666
+ | 2 | 2 | 1.3x - 2.0x |
667
+ | 4 | 4 | **0.3x** — 3x slower than one engine |
668
+
669
+ Four engines overlap in wall-clock terms but each drops from ~9.6 to ~0.7 tok/s: four copies of the weights
670
+ plus their KV caches leave a 16 GB machine with nothing free, and they starve each other. **More engines is
671
+ not more throughput** — and on the 2B, per the table above, a second one is not more throughput either. The
672
+ route to concurrent throughput is batched decode inside one engine (see Current Tasks), not more engines.
673
+
674
+ ## API
675
+
676
+ ### In-process — the library
677
+
678
+ Migrating off `@mlc-ai/web-llm` costs one line; everything after it is unchanged.
679
+
680
+ ```js
681
+ -import { CreateMLCEngine } from "@mlc-ai/web-llm";
682
+ -const engine = await CreateMLCEngine(modelId, { initProgressCallback });
683
+ +import { CreateScheduledEngine } from "everything-webgpu";
684
+ +const engine = await CreateScheduledEngine(modelId, { initProgressCallback });
685
+
686
+ await engine.chat.completions.create({
687
+ messages, stream: true,
688
+ session: "ghost-text", // added — supersedes the previous request
689
+ priority: "interactive", // added — may preempt work that opted in
690
+ });
691
+ ```
692
+
693
+ `chat.completions.create()` returns WebLLM's own shapes, including its
694
+ `"stop" | "length" | "abort"` finish reasons. What it has no room for is `cancelled` and `preempted`
695
+ as distinct outcomes — both collapse to `"abort"` — which is why `complete()` below stays the direct
696
+ API rather than a legacy one.
697
+
698
+ The fuller surface, when you want the store, the model source, or those outcomes:
699
+
700
+ ```js
701
+ import { ScheduledEngine, ModelStore } from "everything-webgpu";
702
+ import { indexedDBStorage, ensurePersistent } from "everything-webgpu/adapters/idb";
703
+
704
+ // A page origin holds weights in *evictable* storage until this is granted.
705
+ await ensurePersistent();
706
+
707
+ const engine = new ScheduledEngine({ store: new ModelStore(await indexedDBStorage()) });
708
+ await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
709
+
710
+ const { text } = await engine.complete({
711
+ messages: [{ role: "user", content: "hi" }],
712
+ session: "ghost-text",
713
+ priority: "interactive",
714
+ });
715
+ ```
716
+
717
+ | method | what it does |
718
+ | --- | --- |
719
+ | `load(id, {keepResident, signal})` | bring a model up; `signal` aborts the download, partial shards kept for a free resume |
720
+ | `use(id)` | switch between **resident** models — free, no reload |
721
+ | `unload(id?)` / `unloadAll()` | free VRAM, **keep the cached bytes** |
722
+ | `resident` | model ids with a live pool right now |
723
+ | `store.evict(id)` | free the disk, **keep the record** so it can be re-fetched |
724
+ | `remove(id)` | forget it entirely — frees bytes for **every** source, then drops the record |
725
+ | `chat.completions.create` | streamed chunks are WebLLM's own, verbatim: `tool_calls`, `logprobs`, stable `created` |
726
+ | `store.cacheState(rec)` | `"cached"` / `"partial"` / `"absent"` |
727
+ | `estimateSpeed(id?)` | projected tok/s, measured once anything has decoded |
728
+ | `features()` | what is actually switched on: KV reuse, decode steps, engines |
729
+ | `complete(req, onChunk?)` | one completion; `onChunk` streams deltas |
730
+ | `batch(req, onItem?)` | many independent prompts as **one task** — see below |
731
+ | `cancel(idOrSession)` | by job id or session key; returns how many it stopped |
732
+ | `configure({ decodeSteps })` | retune a live engine, no reload |
733
+ | `registerModel(spec)` | a base URL, or local `files` |
734
+ | `listModels()` / `listAvailableModels()` | registered only (cheap) / all three routes |
735
+ | `subscribe(fn)` | lifecycle changes; returns an unsubscribe |
736
+ | `state` / `hasWebGPU` | current snapshot, WebGPU presence |
737
+ | `chat.completions.create(req)` | the WebLLM/OpenAI facade over `complete()` |
738
+ | `probe()` | WebGPU, adapter, `shader-f16`, limits, storage quota — cached |
739
+ | `canRun(modelId)` | `{ ok, blockers, warnings }`, before anything is downloaded |
740
+ | `recommendModels({maxVramMB, prefer})` | rank the 163 prebuilt models for *this* device |
741
+
742
+ Failures are `EngineError { code, message, detail }` — `NO_WEBGPU`, `NO_MODEL`, `UNKNOWN_MODEL`,
743
+ `CACHE_INCOMPLETE`, `INVALID_MODEL_FOLDER`, `BAD_REQUEST`, `GENERATION_FAILED`. `detail` carries the
744
+ structured context (the evicted keys, the missing field, why a folder was rejected), so no caller
745
+ parses a message. Over the wire the code rides beside `error`, which stays a plain string.
746
+
747
+ Generation ops take `messages`, `temperature`, `max_tokens`, `response_format` and `extra_body` — the
748
+ OpenAI shape WebLLM already speaks — plus the scheduling fields `task`, `session`, `priority` and
749
+ `preemptible`, which are what this adds over calling WebLLM directly.
750
+
751
+ ### Over a wire — the WebExtension adapter
752
+
753
+ Only for the case where the engine and the caller are in different processes. Extension id:
754
+ `everything-webgpu@local`; the manager page prints a copy-pasteable version.
755
+
756
+ Two transports, one vocabulary ([src/adapters/protocol.js](src/adapters/protocol.js)):
757
+
758
+ - `browser.runtime.sendMessage(id, req)` — request/response. Ops: `status`, `listModels`, `load`,
759
+ `unload`, `chat`, `batch`, `cancel`, `configure`.
760
+ - `browser.runtime.connect(id, { name: "everything-webgpu/v1" })` — streaming. Ops: `subscribe`,
761
+ `chat.stream`, `batch.stream`, `abort`; the port also pushes `engineState` on every lifecycle change.
762
+
763
+ Every message carries `protocol`, so a stray message from another sender fails fast instead of being
764
+ half-interpreted. Generation ops accept `modelId`, `temperature`, `max_tokens`, `response_format`,
765
+ `extra_body`, plus the scheduling fields `task`, `session`, `priority` and `preemptible`.
766
+
767
+ **Send raw requests. Do not ask for a `translate` op.** The engine schedules a shared GPU; it does not
768
+ author prompts. Prompts belong to whoever owns the feature, because they are model-specific — switching this
769
+ build from `Qwen3.5-0.8B` to `Qwen3.8-2B-Distill` changed the conversation template and made every reply open
770
+ with a `<think>` block. A prompt that lives in the caller survives that; a `translate` op baked into the
771
+ engine would have to be rewritten and re-shipped to every caller. Wrap the transport in a client-side helper
772
+ if you want `translate()` ergonomics — just keep it on your side of `sendMessage`.
773
+
774
+ ### The three shapes of work
775
+
776
+ What differs between these is *not* the op or the transport. It is who owns an engine, and what may
777
+ interrupt what. The examples below use the wire form; in-process the same fields go to `complete()` and
778
+ `batch()`.
779
+
780
+ | | op | priority | key fields | why |
781
+ | --- | --- | --- | --- | --- |
782
+ | **Completion** (ghost text) | `chat.stream` | `interactive` | `session` | Each keystroke supersedes the last request; may preempt opted-in work. |
783
+ | **Translation** (a page) | `batch` | `normal` | one shared `task` | One request instead of N, so the engine schedules it as a unit and it never hogs the pool. |
784
+ | **Reformat** (markdown) | `chat` | `background` | `preemptible: true` | Nobody is watching; let interactive work cut in. |
785
+
786
+ #### Completion — latency is the whole product
787
+
788
+ ```js
789
+ const port = browser.runtime.connect("everything-webgpu@local", { name: "everything-webgpu/v1" });
790
+ port.onMessage.addListener((m) => {
791
+ if (m.op === "chunk") render(m.delta);
792
+ if (m.op === "done") finish(m.text);
793
+ });
794
+
795
+ // On every keystroke. The previous request is cancelled, not queued behind.
796
+ port.postMessage({
797
+ protocol: "everything-webgpu/v1",
798
+ op: "chat.stream",
799
+ id: crypto.randomUUID(),
800
+ session: "ghost-text", // supersession key — the important field
801
+ priority: "interactive", // may preempt jobs that opted in
802
+ max_tokens: 24, // ghost text is short; do not pay for more
803
+ messages: [{ role: "user", content: prefix }],
804
+ });
805
+ ```
806
+
807
+ `session` is what makes this work, not `cancel`. Reusing one session key means the engine drops the stale
808
+ request itself; a caller that mints a fresh id per keystroke and calls `cancel` races its own typing.
809
+
810
+ #### Translation — throughput, one task
811
+
812
+ ```js
813
+ // One batch, not a loop of `chat` calls.
814
+ const res = await browser.runtime.sendMessage("everything-webgpu@local", {
815
+ protocol: "everything-webgpu/v1",
816
+ op: "batch",
817
+ task: "translate-page", // optional; a batch is one task either way
818
+ requests: sentences.map((s) => ({
819
+ messages: [{ role: "user", content: `Translate to French, output only the translation:\n${s}` }],
820
+ })),
821
+ });
822
+ res.results.forEach((r) => apply(r.index, r.text));
823
+ ```
824
+
825
+ Every item of one batch shares a task, and a task holds one engine, so a 200-sentence page occupies exactly
826
+ one engine and can never freeze ghost-text behind it. Results carry `engineIndex`, `startedAt` and
827
+ `finishedAt`, so a caller can check what actually ran where.
828
+
829
+ `batch` is still the right call rather than a loop of `chat`: it is one round trip, the engine keeps the
830
+ items in one queue it can reason about, and if this ever runs on a model small enough for fan-out to pay
831
+ again — or once batched decode lands — the same call gets faster with no change on your side.
832
+
833
+ Use `batch.stream` over a port instead if you want items as they land rather than one array at the end.
834
+
835
+ #### Reformat — cheap to interrupt
836
+
837
+ ```js
838
+ await browser.runtime.sendMessage("everything-webgpu@local", {
839
+ protocol: "everything-webgpu/v1",
840
+ op: "chat",
841
+ priority: "background",
842
+ preemptible: true, // the direction matters — see below
843
+ max_tokens: 2048,
844
+ messages: [{ role: "user", content: `Reformat as clean Markdown, no commentary:\n\n${doc}` }],
845
+ });
846
+ ```
847
+
848
+ **Set `preemptible` on the work that can afford to lose, not on the work you care about.** Only an
849
+ `interactive` request preempts, and only a job that opted in can be preempted. A preempted job resolves with
850
+ `preempted: true` and whatever text it had, so it is never requeued and can never starve — but that also
851
+ means you must be able to use, or discard, a partial result.
852
+
853
+ ### Getting these wrong
854
+
855
+ | symptom | cause |
856
+ | --- | --- |
857
+ | Ghost text lags behind typing | Fresh `id` per keystroke with no `session`, so every stale request still runs. |
858
+ | Page translation is slower than expected | Expected: one task is one engine, and a second engine measured 1.06x anyway. Throughput here comes from batched decode, not from more engines. |
859
+ | Reformatting blocks completions | `preemptible` left off the background job, so `interactive` has nothing to take. |
860
+ | Pool stays at one engine | Expected: it grows only when a *second task* waits. Check `status().growthBlocked` if two are waiting and it still has not. |
861
+
862
+ By default every installed extension may call the API. The manager page has an allowlist field; fill it in
863
+ with extension ids to restrict access.
864
+
865
+ ## Layout
866
+
867
+ | Path | Role |
868
+ | --- | --- |
869
+ | [manifest.json](manifest.json) | MV2, persistent background page, `wasm-unsafe-eval` CSP |
870
+ | [src/engine/index.js](src/engine/index.js) | Public entry point of the library |
871
+ | [src/engine/create.js](src/engine/create.js) | `CreateScheduledEngine` — the one-line swap for `CreateMLCEngine` |
872
+ | [src/engine/chat.js](src/engine/chat.js) | `chat.completions.create()`, the WebLLM/OpenAI facade |
873
+ | [src/engine/environment.js](src/engine/environment.js) | `environment()` — the read-only device/runtime report; writes are `configure()` |
874
+ | [src/engine/errors.js](src/engine/errors.js) | `EngineError` and the eight codes |
875
+ | [src/engine/device.js](src/engine/device.js) | Hardware probe, `canRun`, model ranking |
876
+ | [src/engine/engine.js](src/engine/engine.js) | `ScheduledEngine` — the engine with no transport attached |
877
+ | [src/engine/pool.js](src/engine/pool.js) | Engine pool + priority scheduler |
878
+ | [src/engine/engine-worker.js](src/engine/engine-worker.js) | One pool slot's engine, in its own realm |
879
+ | [src/engine/multistep.js](src/engine/multistep.js) | Multi-step decoding: K forward steps per GPU sync |
880
+ | [src/engine/sources.js](src/engine/sources.js) | What `load()` was handed — pure dispatch across id / URL / spec / folder |
881
+ | [src/engine/ingest.js](src/engine/ingest.js) | Folder validation and cache injection |
882
+ | [src/engine/recipes.js](src/engine/recipes.js) | `ask()` / `conversation()` / `ghostText()` — the three shapes as one call each, scheduling only |
883
+ | [src/engine/prefetch.js](src/engine/prefetch.js) | `prefetch()` — fill the cache with no engine and no GPU; WebLLM's `hasModelInCache` is the oracle |
884
+ | [src/engine/model-store.js](src/engine/model-store.js) | Cache layout, registry, settings, `StorageAdapter`, the three model sources |
885
+ | [src/engine/constants.js](src/engine/constants.js) | `PRIORITY`, `ENGINE_STATE` — engine vocabulary, transport-free |
886
+ | [src/adapters/protocol.js](src/adapters/protocol.js) | Wire protocol: `PROTOCOL`, `OP`, `PORT_OP` |
887
+ | [src/adapters/webext.js](src/adapters/webext.js) | `browser.storage.local` + the message/port router |
888
+ | [src/adapters/idb.js](src/adapters/idb.js) | IndexedDB `StorageAdapter` + `ensurePersistent()`, for pages |
889
+ | [src/adapters/memory.js](src/adapters/memory.js) | In-memory `StorageAdapter`, for tests |
890
+ | [src/background/background.js](src/background/background.js) | The extension host: build an engine, attach the transport |
891
+ | [src/popup/](src/popup/) | Minimal test chat (moves to `demo` in Phase 4) |
892
+ | [src/manager/](src/manager/) | Drop target, registry, settings, setup help (moves to `demo` in Phase 4) |
893
+ | [test/integration.test.mjs](test/integration.test.mjs) | The cache-injection contract |
894
+ | [test/scheduler.test.mjs](test/scheduler.test.mjs) | Priority, supersession, preemption and pool growth, GPU-free |
895
+ | [test/sources.test.mjs](test/sources.test.mjs) | How `load()` resolves prebuilt / remote / injected, in what order it refuses, and that a local model has no reachable URL |
896
+ | [test/errors.test.mjs](test/errors.test.mjs) | That failures carry the right code, and that nothing throws an untyped Error |
897
+ | [test/chat.test.mjs](test/chat.test.mjs) | That the WebLLM facade really is drop-in, shape by shape |
898
+ | [test/device.test.mjs](test/device.test.mjs) | The compatibility rules, and that blockers and warnings stay distinct |
899
+ | [test/manage.test.mjs](test/manage.test.mjs) | The four model states, and that unload / evict / remove stay distinct |
900
+ | [test/e2e/](test/e2e/) | Real-hardware end-to-end run (`npm run e2e`) |
901
+ | [test/e2e/bench.mjs](test/e2e/bench.mjs) | Standalone WebGPU sync-latency benchmark (`npm run bench`) |
902
+ | [MLC-COMPILE.md](MLC-COMPILE.md) | How the model was compiled, and every toolchain breakage on the way |
903
+ | [tools/](tools/) | Model-compilation toolchain: setup, nightly patches, weight strip, wasm audits |
904
+ | [WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) | What WebLLM already does, what we add, and where the line is. **Read before adding a capability**; its "Upgrading" section is the dependency-bump runbook. |
905
+ | [ROADMAP.md](ROADMAP.md) | The only list of open work |
906
+ | [ARCHIVE.md](ARCHIVE.md) | What was done and why — the extraction, the model sources, the de-duplication |
907
+
908
+ `src/engine/` references no WebExtension API — asserted by a test, because that claim is only broken in the
909
+ host nobody ran. The three places the host used to leak in are injected: a `StorageAdapter` for the registry,
910
+ a worker URL, and the WebLLM import. `src/adapters/` holds one implementation of each per host. See
911
+ [ARCHIVE.md](ARCHIVE.md).
912
+
913
+ In *this* host the engine lives in the MV2 persistent background page — a real document on the extension
914
+ origin, so it has both `navigator.gpu` and the same Cache Storage the manager page writes to. The model stays
915
+ resident in VRAM across popup opens and across calls from other extensions.
916
+
917
+ ## Known limits
918
+
919
+ - **AMO signing**: `vendor/web-llm.js` is ~6 MB, over `web-ext lint`'s 5 MB parse limit. Fine for temporary
920
+ install and self-distribution; it would need splitting before an AMO listing.
921
+ - **MV2**: MV3 event pages get evicted, which would unload a multi-GB model between calls. Migrating needs a
922
+ keep-alive or a dedicated engine tab. This constrains `demo` only — the library is host-agnostic.
923
+ - **Storage eviction off an extension origin**: an ordinary page has no `unlimitedStorage`, so a multi-GB
924
+ model is evictable until `ensurePersistent()` is granted. For a prebuilt or remote model that means a slow
925
+ reload; for a locally-registered one it is fatal and it must be re-registered. Unmeasured: the exact quota
926
+ and grant behaviour per browser.
927
+ - **Never run outside a Firefox extension**: the library is written against capabilities rather than
928
+ browsers, and should be *faster* on Chrome (KV reuse is not disabled there — see "Multi-step decoding").
929
+ Both claims are predictions. See ROADMAP.md, Gates A and B.
930
+ - **Thinking burns the budget**: this model opens every reply with a `<think>` block — it is a reasoning
931
+ distill and its card says so. At ~17 tok/s that is seconds of nothing before the answer starts. For
932
+ translation and completion, suppress it in your prompt, or rebuild the config with the `qwen3_5_nothink`
933
+ conversation template. The engine deliberately does not rewrite prompts for you.
934
+ - **No cross-turn KV reuse**: every turn re-prefills the whole history at 5.27 ms/token, so a long
935
+ conversation pays ~22 s before its first token at the 4096 limit. See Current Tasks.
936
+ - **Decode speed**: *was* bounded by Firefox's 100 ms WebGPU poll timer (Mozilla bug 1870699). Multi-step
937
+ decoding and compute-pass batching bought most of that back; what remains is memory bandwidth — decode
938
+ achieves ~16 GB/s of the M4's ~120. See "The 10 tok/s ceiling" and "Why not llama.cpp/Ollama-class".
939
+ - **A second engine is not more speed**: measured 1.06x on this model. It buys isolation between tasks.
940
+ Concurrent throughput needs batched decode inside one engine. See "Scheduling".
API.md ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API — every way to call it
2
+
3
+ The complete call surface of `everything-webgpu`, one page. [README.md](README.md) is the pitch and
4
+ the migration story; this is the catalogue. Asserted against the code by
5
+ [test/api-doc.test.mjs](test/api-doc.test.mjs) — every engine method named here exists, every
6
+ package export appears, and the error table equals `ERROR`, so this page cannot drift from the
7
+ source without failing `npm test`.
8
+
9
+ The page is in three tiers. **[Start here](#start-here)** — `load`, `ask`, `conversation`,
10
+ `environment` — is the dead-simple path, and most apps need nothing else. **[Native
11
+ passthrough](#native-passthrough)** is `chat.completions.create()`, the WebLLM/OpenAI compatibility
12
+ layer, which **never changes** (see [Stability](README.md#stability)). **[When you need
13
+ more](#when-you-need-more)** is the rest of the surface: the general calls and their scheduling
14
+ fields, embeddings, residency, device inspection, configuration, lifecycle. Everything outside the
15
+ passthrough is pre-1.0 and may move; the ergonomic verbs are being consolidated in
16
+ [ROADMAP.md](ROADMAP.md).
17
+
18
+ ---
19
+
20
+ ## The four lines
21
+
22
+ ```js
23
+ import { CreateScheduledEngine } from "everything-webgpu"; // 1. import
24
+ const engine = await CreateScheduledEngine("Llama-3.2-1B-Instruct-q4f16_1-MLC"); // 2. load a model
25
+ const reply = await engine.ask("Name three primary colours."); // 3. ask
26
+ console.log(reply); // 4. the answer
27
+ ```
28
+
29
+ `reply` is a plain string. Line 2 downloads ~0.8 GB the first time and prints throttled progress to
30
+ the console unless you pass `initProgressCallback` (render it yourself) or `initProgressCallback:
31
+ null` (silence). After that first run it is a cache read and needs no network.
32
+
33
+ On Vite, add one plugin — see [Bundlers](#bundlers).
34
+
35
+ ---
36
+
37
+ ## Start here
38
+
39
+ Four calls. Get an engine with `CreateScheduledEngine` (below, under [Getting an
40
+ engine](#getting-an-engine)), then `load` a model, `ask` it things or hold a `conversation`, and
41
+ `environment` tells you whether the machine is up to it.
42
+
43
+ ### Load a model — `engine.load(src, opts?)`
44
+
45
+ **One call, four source shapes.** `engine.load(src, opts?)` works out what you handed it, registers
46
+ whatever needs registering, and brings the model up.
47
+
48
+ | `src` | route |
49
+ | --- | --- |
50
+ | `"Llama-3.2-1B-Instruct-q4f16_1-MLC"` | a prebuilt id, or anything you registered earlier. A typo is answered with near matches. |
51
+ | `"https://huggingface.co/mlc-ai/Foo-MLC"` | an HF repo. `/resolve/main/` is **not** derived — WebLLM appends it. |
52
+ | `"https://cdn.example/models/foo/"` + `{ modelLib }` | any base URL you host. `modelLib` is **required** and never guessed (0 of 163 prebuilt models have a derivable lib name or same-origin lib). |
53
+ | `{ model, modelLib }` | the explicit remote spec. |
54
+ | `input.files` \| `dropEvent.dataTransfer` \| `{ files }` | a folder off disk. **No network at any point.** |
55
+
56
+ **`opts`** — `keepResident`, `signal`, `modelType`, `contextWindow`, `vramRequiredMB`, `id`,
57
+ `onProgress`, `defer`.
58
+
59
+ - `id` overrides the id derived from the URL's last segment.
60
+ - `defer: true` registers the source **without building a pool** — the drop-now-load-later flow. It
61
+ returns the registry record. `defer` on a bare prebuilt id is an error, not a silent load.
62
+ - `keepResident: true` holds this model in VRAM alongside whatever is already up. The default
63
+ unloads everything else first — the safe choice on a 16 GB machine.
64
+
65
+ ```js
66
+ await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
67
+ await engine.load("https://cdn.example/models/my-model/", { modelLib: "https://cdn.example/models/my-model/lib.wasm" });
68
+ await engine.load(dropEvent.dataTransfer);
69
+ await engine.load(input.files, { defer: true }); // register now, build the pool on first use
70
+ ```
71
+
72
+ `load()` composes the lower-level `registerModel`, `ingestModelFolder` and the download primitives;
73
+ `prefetch()` warms the cache with no GPU. All of that is under [More on
74
+ loading](#more-on-loading).
75
+
76
+ ### Ask one question — `engine.ask(input, opts?)`
77
+
78
+ One question, its own task, **no session** — two `ask()`s never supersede each other. Returns the
79
+ reply string. `opts.onDelta` to stream. Goes through the same scheduler as everything else: priority
80
+ bands, one-task-one-engine, opt-in preemption.
81
+
82
+ ### Hold a conversation — `engine.conversation(opts)`
83
+
84
+ A multi-turn chat that keeps its own history. `engine.conversation({ system?, keep?, ...defaults })`
85
+ — one stable task for every turn, turns serialised, history bounded at `keep: 12` exchanges
86
+ (`Infinity` opts out).
87
+
88
+ ```js
89
+ const chat = engine.conversation({ system: "You are terse." });
90
+ await chat.say("capital of France?"); // → { text, finishReason }
91
+ await chat.say("and its population?", onDelta); // remembers
92
+ chat.messages; chat.length; chat.reset(); chat.restore(messages);
93
+ ```
94
+
95
+ ### Inspect the machine — `engine.environment(opts?)`
96
+
97
+ | call | answers |
98
+ | --- | --- |
99
+ | `await engine.environment(opts?)` | **the preflight.** A report; every line has `severity` · `affects` · `cause` · `fix` · `operable`. `{ scope: "local" }` never touches the model layer (poll freely); `{ scope: "device" }` is hardware only. |
100
+ | `await engine.environment.measure()` | one calibration generation → measured tok/s for the current model |
101
+
102
+ **`environment()` only reports.** Writes go through `configure()`; passing a setting to
103
+ `environment()` is an error that names `configure()`. Per-model "will it run" is
104
+ [`canRun(modelId)`](#inspecting-the-machine); the fuller device surface is there too.
105
+
106
+ ## Native passthrough
107
+
108
+ The one call that **never changes**. `@mlc-ai/web-llm` is OpenAI-shaped, and so is this: the
109
+ migration off it is a one-line import swap, and `chat.completions.create()` then takes and returns
110
+ exactly the same shapes — same streamed chunk objects, same finish reasons, same non-streaming
111
+ envelope. See [Stability](README.md#stability).
112
+
113
+ | call | shape |
114
+ | --- | --- |
115
+ | `engine.chat.completions.create(params)` | the **WebLLM/OpenAI** shape, unchanged. Streams the same chunks, same finish reasons. `session`/`priority`/`task`/`preemptible` are additive. |
116
+
117
+ **`params`** = the OpenAI generation fields WebLLM already speaks (`messages`, `temperature`,
118
+ `max_tokens`, `response_format`, `extra_body`, …) **plus** the scheduling fields that are the only
119
+ thing this adds over calling WebLLM directly:
120
+
121
+ | field | meaning |
122
+ | --- | --- |
123
+ | `modelId` | load/route to this model instead of the current one |
124
+ | `id` | job id; also what `cancel(id)` takes |
125
+ | `task` | the unit that owns an engine; a whole batch shares one |
126
+ | `session` | a later job with this key supersedes the earlier one |
127
+ | `priority` | `"interactive"` \| `"normal"` \| `"background"` |
128
+ | `preemptible` | may be interrupted by an `interactive` job (set it on the work that can afford to lose) |
129
+
130
+ `complete()`, `completeRaw()` and `batch()` take the same scheduling fields and expose `cancelled` /
131
+ `preempted` as first-class outcomes the OpenAI shape has no room for — see [The general
132
+ calls](#the-general-calls).
133
+
134
+ ## When you need more
135
+
136
+ ### Getting an engine
137
+
138
+ | call | when |
139
+ | --- | --- |
140
+ | `await CreateScheduledEngine(modelId?, opts?)` | the common case. Loads `modelId` before returning, like WebLLM's `CreateMLCEngine`. Omit it for an engine that loads later. |
141
+ | `new ScheduledEngine({ store, workerUrl?, loadWebLLM?, prebuilt? })` | when you must pass a store explicitly — a worker, a test, an extension. Does **not** load anything. |
142
+
143
+ **`opts` for `CreateScheduledEngine`** — `store`, `initProgressCallback`, `workerUrl`, `loadWebLLM`,
144
+ `prebuilt`. Anything not `store`/`initProgressCallback` is forwarded to the constructor.
145
+
146
+ | constructor field | default | meaning |
147
+ | --- | --- | --- |
148
+ | `store` | IndexedDB (`CreateScheduledEngine` only; the constructor requires it) | a `ModelStore`, or a bare `StorageAdapter` it wraps |
149
+ | `prebuilt` | `true` | expose WebLLM's 163 HuggingFace models. `false` = offline-only: `load()` resolves registered models and nothing else, and an unknown id fails before the WebLLM bundle is fetched |
150
+ | `workerUrl` | `new URL("./engine-worker.js", import.meta.url)` | the decode worker's module URL |
151
+ | `loadWebLLM` | `() => import("../../vendor/web-llm.js")` | override the bundle source (tests) |
152
+
153
+ **Stores** — `import { indexedDBStorage } from "everything-webgpu/adapters/idb"` (pages, plus
154
+ `ensurePersistent()`), `everything-webgpu/adapters/memory` (`memoryStorage()`, tests),
155
+ `everything-webgpu/adapters/webext` (`webExtensionStorage()` + `attachWebExtensionTransport()`).
156
+
157
+ ```js
158
+ import { ScheduledEngine, ModelStore } from "everything-webgpu";
159
+ import { memoryStorage } from "everything-webgpu/adapters/memory";
160
+ const engine = new ScheduledEngine({ store: new ModelStore(memoryStorage()) });
161
+ ```
162
+
163
+ ### More on loading
164
+
165
+ **Warming the cache first** — `await engine.prefetch(modelId, { onProgress, signal })`. Downloads
166
+ the weights **without building an engine and without WebGPU**, so an app can warm the cache before
167
+ it knows whether the machine can run the model. Interrupted downloads resume; a second call is free.
168
+ WebLLM cannot express this — `reload()` needs a GPU before it fetches a shard.
169
+
170
+ **Low-level, still exported** — `load()` composes these rather than replacing them:
171
+
172
+ | call | does |
173
+ | --- | --- |
174
+ | `engine.registerModel(spec)` | add a `{ modelId, model, modelLib }` or `{ modelId, files }` record, no pool |
175
+ | `ingestModelFolder(entries, { store })` | folder → populated Cache Storage, returns the record |
176
+ | `filesFromInput(input.files)` / `filesFromDataTransfer(dt)` | either browser shape → flat `{ path, file }[]` (the latter is async) |
177
+ | `prefetchModel({ modelId, record, ... })` / `resolveModelUrl(...)` | the engine-free download primitives |
178
+
179
+ ### The general calls
180
+
181
+ Every call here goes through the **same scheduler** as `ask` / `conversation` / `chat.completions`:
182
+ priority bands, `session` supersession, one-task-one-engine, opt-in preemption.
183
+
184
+ | call | shape |
185
+ | --- | --- |
186
+ | `await engine.complete(payload, onChunk?)` | `{ text, usage, finishReason, cancelled?, preempted? }`. `onChunk(delta)` streams plain text. |
187
+ | `await engine.completeRaw(payload, onRawChunk?)` | same, but `onRawChunk` gets WebLLM's chunk object verbatim. |
188
+ | `await engine.batch({ requests, task?, ...sched }, onItem?)` | `requests` fanned across the pool as **one task**. Returns `BatchItem[]` — each with `index`, `engineIndex`, `startedAt`, `finishedAt`, and `text`/`usage` or `error`. |
189
+
190
+ **`payload`** is the OpenAI generation fields plus the scheduling fields — the same table as [Native
191
+ passthrough](#native-passthrough) (`modelId`, `id`, `task`, `session`, `priority`, `preemptible`).
192
+
193
+ Result flags: `cancelled: true` (superseded or `cancel()`ed), `preempted: true` (`text` is partial).
194
+
195
+ ### Ghost text — `engine.ghostText(opts)`
196
+
197
+ `engine.ghostText({ prompt, debounceMs?, maxTokens?, session?, ...defaults })` — debounce + one
198
+ session key + `interactive` priority + **resolves `null` when stale**. `prompt` is **required**, no
199
+ default: prompts are model-specific and belong to whoever owns the feature.
200
+
201
+ ```js
202
+ const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` });
203
+ const hint = await ghost.suggest(editor.textBefore()); // string | null
204
+ ghost.cancel(); // on blur / accept
205
+ ```
206
+
207
+ ### Embeddings
208
+
209
+ Needs an **embedding model** (`snowflake-arctic-embed-*`, from 239 MB), usually held resident
210
+ alongside a chat model.
211
+
212
+ | call | returns |
213
+ | --- | --- |
214
+ | `await engine.embed(input, opts?)` | `number[][]` — one vector per input, in order |
215
+ | `await engine.embedRaw(input, opts?)` | WebLLM's OpenAI envelope (`data[].embedding`) |
216
+
217
+ `opts`: `modelId`, `task`, `session`, `priority`, `preemptible`, `id`. **A running embedding cannot
218
+ be interrupted** — one forward pass has no decode loop to break out of; queued embeddings supersede
219
+ normally.
220
+
221
+ ### Residency and cache
222
+
223
+ A resident model is a full copy of its weights in VRAM, and nothing reports free VRAM to a page —
224
+ so residency is explicit.
225
+
226
+ | call | frees | keeps |
227
+ | --- | --- | --- |
228
+ | `await engine.unload()` | current model's VRAM | cache + registry |
229
+ | `await engine.unload(id)` | that model's VRAM | cache + registry |
230
+ | `await engine.unload(id, "cache")` | VRAM + cached bytes | registry entry |
231
+ | `await engine.unloadAll()` | every resident model's VRAM | cache + registry |
232
+ | `await engine.remove(id)` | bytes + registry entry | nothing — for an injected model, means re-supplying the folder |
233
+ | `engine.evict(id)` | low-level primitive `unload(id, "cache")` is built on | registry entry |
234
+
235
+ **Routing without loading** — `engine.use(id)` points unaddressed requests at an already-resident
236
+ model (free and instant; `load()` is what costs). `engine.resident` lists model ids with a live
237
+ pool. `await engine.cacheState(id)` says what is on disk (`"complete"` / `"partial"` / absent).
238
+
239
+ ### Inspecting the machine
240
+
241
+ The device surface behind the [Start here](#start-here) preflight.
242
+
243
+ | call | answers |
244
+ | --- | --- |
245
+ | `await engine.canRun(modelId)` | per-**model**: `{ ok, blockers, warnings }`, before anything downloads |
246
+ | `await engine.recommendModels(opts?)` | which models this device should be asked to run, best first. `opts`: `maxVramMB`, `needsVision`, `needsToolCalling`, `prefer` |
247
+ | `await engine.estimateSpeed(modelId?)` | projected decode tok/s (uses the measured rate once one generation has happened) |
248
+ | `await engine.probe()` | raw device probe: WebGPU, adapter, `shader-f16`, the five limits, storage quota. Cached. |
249
+ | `await engine.features()` | what is switched **on** now, vs what the device could support. `multiStepOff` is non-null when decode fell back to one GPU sync per token — the silent halving `environment()` reports as `degraded` |
250
+ | `engine.hasWebGPU` | `Boolean(navigator.gpu)` |
251
+ | `await engine.listAvailableModels()` | registered + prebuilt, normalised. Costs one bundle fetch. |
252
+ | `engine.listModels()` | registered only — cheap, no bundle load |
253
+
254
+ ### Configuration
255
+
256
+ `await engine.configure(patch)` — applies a runtime knob and persists it as the default.
257
+
258
+ | knob | effect |
259
+ | --- | --- |
260
+ | `decodeSteps` | forward steps per GPU sync. Hot, no reload. `1`–`32` (`DEFAULT_DECODE_STEPS` = 15). |
261
+ | `engineCount` | pool size. Persisted; live pools keep the size they came up with. |
262
+ | `temperature`, `maxTokens`, `systemPrompt` | generation defaults (`DEFAULT_SETTINGS`) |
263
+
264
+ Not operable from JS, report-only via `environment()`: KV reuse (derived from the 9-storage-buffer
265
+ cap), compute-pass batching (build-time `NO_PASS_MERGE`), `shader-f16`, GPU, `about:config` flags.
266
+
267
+ ### Lifecycle and cancellation
268
+
269
+ | call | |
270
+ | --- | --- |
271
+ | `const stop = engine.subscribe(listener)` | `listener(state)` fires immediately, then on every change. Returns unsubscribe. |
272
+ | `engine.state` | snapshot: `status`, `modelId`, `progress`, `error`, `pool {size,busy,queued,maxSize,growthBlocked}`, `resident`, `decode` |
273
+ | `engine.store` | the `ModelStore`, so a host can drive the registry without a second handle |
274
+ | `engine.cancel(idOrSession)` | cancel by job id or by session key |
275
+ | `engine.load(id, { signal })` | an `AbortController` signal tears down an in-flight download |
276
+
277
+ `state.status` is one of `ENGINE_STATE`: `"idle"` · `"loading"` · `"ready"` · `"error"`.
278
+
279
+ ## Errors
280
+
281
+ Every failure is an `EngineError` with a `.code`, a human-readable `.message` (the thing you print),
282
+ and structured `.detail`. `import { isEngineError, ERROR } from "everything-webgpu"`.
283
+
284
+ | code | what to do |
285
+ | --- | --- |
286
+ | `NO_WEBGPU` | tell the user to check flags/hardware; retrying is futile |
287
+ | `NO_MODEL` | nothing registered — send them to your setup flow |
288
+ | `UNKNOWN_MODEL` | that id is not resolvable; `listAvailableModels()` says what is |
289
+ | `CACHE_INCOMPLETE` | a locally-registered model was evicted; re-register the folder |
290
+ | `INVALID_MODEL_FOLDER` | not a compiled MLC model; `detail` says what is missing |
291
+ | `BAD_REQUEST` | the caller's arguments are wrong — a bug in the caller |
292
+ | `ABORTED` | the caller cancelled it. Not a failure; do not report it as one |
293
+ | `GENERATION_FAILED` | the model failed mid-generation |
294
+ | `PACKAGE_INCOMPLETE` | your **build** is wrong, not your code — missing `vendor/` bundle, or a decode worker the bundler did not emit. `message` names the fix; `detail.cause` says which |
295
+
296
+ ```js
297
+ try { await engine.load(id); }
298
+ catch (err) {
299
+ if (isEngineError(err, ERROR.CACHE_INCOMPLETE)) return reRegisterFolder();
300
+ throw err;
301
+ }
302
+ ```
303
+
304
+ ## Bundlers
305
+
306
+ The engine spawns its decode worker with `new Worker(new URL("./engine-worker.js",
307
+ import.meta.url), { type: "module" })`. On **Vite**, its dependency pre-bundler rewrites that URL to
308
+ a path that 404s — in `vite dev`, on a real (non-linked) install only. Add the plugin:
309
+
310
+ ```js
311
+ import { everythingWebGPU } from "everything-webgpu/vite";
312
+ export default defineConfig({ plugins: [everythingWebGPU()] });
313
+ ```
314
+
315
+ Equivalent by hand: `optimizeDeps: { exclude: ["everything-webgpu"] }`. Skip both and `load()`
316
+ throws `PACKAGE_INCOMPLETE` naming the fix rather than hanging. `vite build` is unaffected either
317
+ way. Other bundlers that honour `new URL(..., import.meta.url)` for workers (Webpack 5, Rollup,
318
+ Parcel 2) need nothing.
319
+
320
+ ## Every export
321
+
322
+ `import { … } from "everything-webgpu"` — 43 names.
323
+
324
+ **Engine & entry** — `ScheduledEngine`, `CreateScheduledEngine`, `EnginePool`
325
+
326
+ **Model sources** — `ModelStore`, `ingestModelFolder`, `filesFromInput`, `filesFromDataTransfer`,
327
+ `prefetchModel`, `resolveModelUrl`, `isInjected`, `baseUrlFor`, `groupKeysByScope`, `toAppConfig`
328
+
329
+ **Recipes** (also methods on the engine) — `ask`, `conversation`, `ghostText`
330
+
331
+ **Device** — `probeDevice`, `canRun`, `projectSpeed`, `rankModels`, `REFERENCE_DECODE_BYTES_PER_SECOND`
332
+
333
+ **Multi-step decoding** — `installMultiStepDecoding`, `burstSize`, `clampSteps`,
334
+ `DEFAULT_DECODE_STEPS`, `MAX_DECODE_STEPS`
335
+
336
+ **Errors** — `EngineError`, `ERROR`, `isEngineError`, `asEngineError`
337
+
338
+ **Formatting** — `formatBytes`
339
+
340
+ **Enums / constants** — `PRIORITY`, `PRIORITY_ORDER`, `ENGINE_STATE`, `UNLOAD_LEVEL`, `SEVERITY`,
341
+ `MODEL_TYPE`, `SOURCE`, `DEFAULT_SETTINGS`, `WORKER_CONFIGURE`, `CACHE_CONFIG`, `CACHE_MODEL`,
342
+ `CACHE_WASM`
343
+
344
+ ### Enum values
345
+
346
+ | enum | values |
347
+ | --- | --- |
348
+ | `PRIORITY` | `interactive` · `normal` · `background` |
349
+ | `ENGINE_STATE` | `idle` · `loading` · `ready` · `error` |
350
+ | `UNLOAD_LEVEL` | `vram` · `cache` |
351
+ | `SEVERITY` | `blocked` · `degraded` · `tune` · `info` · `ok` |
352
+ | `SOURCE` | `prebuilt` · `remote` · `injected` |
353
+ | `MODEL_TYPE` | `llm` = 0 · `embedding` = 1 · `vlm` = 2 |
354
+ | `CACHE_*` | `webllm/config` · `webllm/model` · `webllm/wasm` |
355
+ | `DEFAULT_SETTINGS` | `engineCount: 2`, `decodeSteps: 15`, `temperature: 0.6`, `maxTokens: 1024`, `systemPrompt: ""` |
356
+
357
+ ## Subpath exports
358
+
359
+ | specifier | |
360
+ | --- | --- |
361
+ | `everything-webgpu` | everything above |
362
+ | `everything-webgpu/vite` | `everythingWebGPU()` Vite plugin |
363
+ | `everything-webgpu/worker` | the decode worker entry (for a custom `workerUrl`) |
364
+ | `everything-webgpu/adapters/idb` | `indexedDBStorage()` |
365
+ | `everything-webgpu/adapters/memory` | `memoryStorage()` |
366
+ | `everything-webgpu/adapters/webext` | `webExtensionStorage()`, `attachWebExtensionTransport()` |
367
+ | `everything-webgpu/adapters/protocol` | the wire-protocol constants |
ARCHIVE.md ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Archive — done, and why
2
+
3
+ Completed work, kept for its **reasoning** rather than its status. A decision recorded without its
4
+ cause gets re-litigated by the next session, or quietly reversed.
5
+
6
+ Open work is in [ROADMAP.md](ROADMAP.md). What is true and measured is in [AI.md](AI.md).
7
+
8
+ ---
9
+
10
+ ## The extraction — `main` became a library
11
+
12
+ `main` is a library for developers embedding a local model in their own app; `demo` keeps the Firefox
13
+ extension and is the library's first consumer.
14
+
15
+ **Phase 0 — froze the extension.** `demo` branch, `demo-baseline` tag.
16
+
17
+ **Phase 1 — decoupled the three platform dependencies.** `background.js` went from 390 lines to 22.
18
+ The engine core (`src/engine/`) references no WebExtension API, asserted by a test — that claim is
19
+ only ever broken in the host nobody ran.
20
+
21
+ | | before | after |
22
+ | --- | --- | --- |
23
+ | Registry | `browser.storage.local` ×4 | `ModelStore` over an injected `StorageAdapter` — deliberately `get(key)`/`set(obj)`, the exact shape of `browser.storage.local`, so the WebExtension adapter is a passthrough |
24
+ | Worker | `browser.runtime.getURL(…)` | `new URL("./engine-worker.js", import.meta.url)` — understood by Vite/webpack/esbuild *and* correct on `moz-extension://`, so it replaces `getURL` rather than sitting beside it |
25
+ | Transport | 6 `browser.runtime` listeners | `attachWebExtensionTransport(engine)`; wire format unchanged byte-for-byte |
26
+
27
+ The wire protocol was **demoted**, not removed: in a page there is no `sendMessage`, so `OP`/`PORT_OP`
28
+ became one adapter's vocabulary rather than the interface.
29
+
30
+ **Phase 2 — the developer-facing surface.**
31
+
32
+ - `CreateScheduledEngine()` + `chat.completions.create()`. Migration off `@mlc-ai/web-llm` costs one
33
+ line; everything after it is unchanged. `complete()`/`batch()` keep the names so `chat` stays free
34
+ for the facade.
35
+ - `EngineError { code, message, detail }` — 8 codes, each existing because a caller does something
36
+ *different* about it. A test walks `src/engine/` for bare `throw new Error`, because one untyped
37
+ throw forces every caller back to string matching.
38
+ - `.d.ts` generated from the JSDoc that was already there; only the request/result typedefs were
39
+ added. Source stays plain ESM.
40
+ - `@mlc-ai/web-llm` pinned to exactly `0.2.84` — `build.mjs` patches the bundle by string anchor and
41
+ throws on a miss, so a caret range would break every consumer's build.
42
+
43
+ ## Zero-download stopped being a constraint
44
+
45
+ It existed because the *extension* could not reasonably download. A developer embedding a model
46
+ usually can, and often must. So a model now arrives by one of three routes, resolved by `load()`:
47
+
48
+ | route | how | validation |
49
+ | --- | --- | --- |
50
+ | `prebuilt` | one of WebLLM's 163 HuggingFace models | none needed |
51
+ | `remote` | `registerModel({ modelId, model, modelLib })`, any base URL | none — a bad URL is reported far better by WebLLM's loader than by a HEAD request |
52
+ | `injected` | `registerModel({ modelId, files })`, **no network at any point** | exhaustive, before the first byte is written |
53
+
54
+ Three things fell out that were not obvious going in:
55
+
56
+ 1. **It cost almost no code.** `toAppConfig` already emitted `{model, model_id, model_lib}`; it had
57
+ only ever been handed `.invalid` URLs.
58
+ 2. **It retired the top risk.** Cache injection on an ordinary page origin was risk #1 because the
59
+ whole design rested on it. It is now the offline route only.
60
+ 3. **The routes cannot be confused, so downloads default on.** `.invalid` is reserved by RFC 6761
61
+ and can never resolve, so an injected model whose cache was evicted fails with a DNS error rather
62
+ than silently pulling a gigabyte. That is the *mechanism* of the offline guarantee, not a label
63
+ for it — a test asserts every URL such a record carries is on a `.invalid` host.
64
+
65
+ **Why local upload keeps a synthetic origin.** WebLLM composes every artifact URL as
66
+ `new URL(relative, base)` and runs the base through `cleanModelUrl`, which calls `new URL(...)` — so
67
+ the base must be absolute and resolvable. A `blob:` URL cannot serve as one, and there is no hook to
68
+ hand the loader bytes directly. Pre-populating the cache under WebLLM's own scopes and keys *is* its
69
+ native path. Seeding the cache for a URL the developer hosts was rejected: it would unify the record
70
+ shapes but make eviction silently re-download a gigabyte, which is the failure the design prevents.
71
+
72
+ ## Model lifecycle — four states, three operations
73
+
74
+ The old API collapsed them, and that was a real bug: `remove()` deleted the cache **and** the
75
+ registry record, so for a remote model it threw away the only URL the bytes could be fetched from.
76
+
77
+ | state | VRAM | cache | record | leave via |
78
+ | --- | --- | --- | --- | --- |
79
+ | resident | ✅ | ✅ | ✅ | `unload(id)` |
80
+ | cached | — | ✅ | ✅ | `evict(id)` |
81
+ | registered | — | — | ✅ | `remove(id)` |
82
+ | unknown | — | — | — | — |
83
+
84
+ **Multiple resident models.** `#pool` became `#pools: Map<modelId, EnginePool>` with a current
85
+ selection. `use(id)` switches for free; a request naming a resident model routes to it *without*
86
+ changing which is current. Additive residency is opt-in (`{ keepResident: true }`) because each
87
+ resident model is a full copy of its weights and nothing reports free VRAM to a page.
88
+
89
+ ## Device and compatibility
90
+
91
+ `probeDevice()` / `canRun()` / `rankModels()` answer "will this run here" before a byte is fetched.
92
+ The rules are this project's platform scars as code: the blocklisted adapter, Firefox's
93
+ 9-storage-buffer cap, `q4f16_1` on a device without `shader-f16`.
94
+
95
+ Two things learned while building it:
96
+
97
+ - **Blockers and warnings must stay separate.** The 9-buffer cap costs KV reuse but is a *warning* —
98
+ blocking it would refuse the exact configuration this project ships on.
99
+ - **"Largest that fits" is bad default advice.** Decode is memory-bandwidth-bound, so the largest
100
+ model that fits is also the slowest thing that fits. `prefer: "quality" | "speed"` makes it the
101
+ caller's choice rather than an assumption.
102
+
103
+ `probeDevice()` never throws — an unusable device is a result to explain, not an exception.
104
+
105
+ ## Stop reinventing WebLLM
106
+
107
+ An audit ([WEBLLM-SURFACE.md](WEBLLM-SURFACE.md)) found three functions reimplemented that WebLLM
108
+ exported all along. Cause: the bundle was treated as something to `grep` for narrow facts rather than
109
+ an API to survey once — `cleanModelUrl` was even *read on screen* and then rewritten, without asking
110
+ what else used it. This violated AI.md's own **Reuse First** principle.
111
+
112
+ | Removed | Replaced by |
113
+ | --- | --- |
114
+ | `cleanModelUrl()` reimplemented | nothing — it only propped up the two below |
115
+ | `ModelStore.cacheKeysFor()` | WebLLM derives the keys it fetched |
116
+ | `ModelStore.cacheState()` | `engine.cacheState()` → `hasModelInCache` for remote |
117
+ | `ModelStore.evict()` (all sources) | `evictInjected()` + `engine.evict()` → `deleteModelAllInfoInCache` |
118
+ | speed re-derived from the worker probe | `usage.extra.decode_tokens_per_s`, already on every response |
119
+
120
+ The routing rule is now explicit: **whoever wrote the bytes owns the keys.** Our path survives only
121
+ where it demonstrably does more — WebLLM's delete and cache-check both read `tensor-cache.json` to
122
+ enumerate shards, so once *that* file is evicted they can neither find nor clean the shards it
123
+ indexes. Injected records carry an explicit key list and have no such failure. There is a test for
124
+ exactly that case, and it is the only justification for keeping the code.
125
+
126
+ **Speed was worse than duplication:** the pool already set `include_usage` and already stored
127
+ `chunk.usage`, so the measurement was being *received and discarded* so the worker probe could
128
+ recompute it.
129
+
130
+ ## Raw chunk pass-through, and the tool-calling bug
131
+
132
+ The pool stripped every chunk to `delta.content` and the facade rebuilt an envelope from scratch —
133
+ so `tool_calls` was dropped entirely (**tool calling returned nothing usable**), `logprobs` was
134
+ always `null`, and `created` was restamped per chunk. Chunks now pass through verbatim.
135
+
136
+ Nothing is synthesized on the normal path: WebLLM emits its own terminal `finish_reason` chunk and
137
+ its own usage chunk. The one exception is an interrupted generation, where the stream simply stops
138
+ and a consumer would otherwise never learn why.
139
+
140
+ **A correction to the plan that produced this.** It specified `mergeToolCallDeltas()` for
141
+ "standard OpenAI fragment accumulation". WebLLM does not stream fragments — it parses the whole
142
+ output message at the end and emits tool calls complete in one terminal chunk. Building the merge
143
+ would have been machinery for a wire shape that is never produced: the plan's own failure mode,
144
+ inside the plan meant to prevent it.
145
+
146
+ ## Bugs found and fixed along the way
147
+
148
+ | | |
149
+ | --- | --- |
150
+ | `EnginePool.load()` leaked an engine | It awaited `createEngine` before assigning `#slots`, so an `unload()` in that window tore down an *empty* pool and the engine then installed itself into a pool nobody referenced — leaking a worker and a full copy of the weights. `#grow()` had always guarded this; `load()` never did. Fixed with a `#generation` counter. |
151
+ | `registerModel` accepted URLs that fail at load | WebLLM's `cleanModelUrl` ends in `new URL()` with no base, so a relative `/models/x/` throws deep in the loader. Now resolved at registration. |
152
+ | `state.modelId` / `resident` went stale | Views onto `#pools` that nothing re-synced after unload, and left pointing at a model that never came up after a failed load. |
153
+ | `probeDevice` threw on a partial `navigator.gpu` | A polyfill without `requestAdapter` produced a TypeError from a function documented never to throw. |
154
+ | The pool discarded `finish_reason` | A `max_tokens` truncation was indistinguishable from the model choosing to stop. |
155
+ | `store.remove()` stranded remote shards | It iterates `groupKeysByScope`, empty for remote records — and deleting the entry destroys the only URL those bytes could be derived from. Now `engine.remove()` evicts first. |
156
+ | `throw`-as-`goto` in `multistep.js` | Caught two lines below; replaced with the control flow it was emulating. |
157
+ | `features()` called a dead fast path healthy | It answered "is multi-step on?" with `decodeSteps > 1`, but the worker keeps posting the *configured* K — 15 — long after the contract check routed decoding to stock single-step. So the one call documented as "what is switched **on** now" reported `multiStepDecoding: true` for the exact fault it exists to surface, and never exposed `multiStepOff` at all. `environment()` escaped it only by reading `state.decode.multiStepOff` itself rather than trusting `features()`. Now `multiStepDecoding` is `decodeSteps > 1 && !multiStepOff?.length`, and `multiStepOff` is returned beside it. `decodeSteps` deliberately keeps reporting the knob's value: dropping it to 1 would make `environment()` advise `configure({ decodeSteps: 15 })` for a fault no setting can fix. |
158
+
159
+ ## Corrections to the record
160
+
161
+ Kept because each was stated confidently and was wrong; a future reader should not re-derive them.
162
+
163
+ - **"A second engine measured 1.06x, so batched decode is the only route to concurrent throughput."**
164
+ This framed two complementary mechanisms as substitutes. A second engine buys *task isolation* —
165
+ a translation and a ghost-text completion running at once — and never claimed aggregate
166
+ throughput; the GPU is already saturated by a 2B. Batched decode makes *one task's* many requests
167
+ faster. Neither substitutes for the other.
168
+ - **"Multi-model residency via `#pools` was not necessary."** Wrong. `reload()` unconditionally calls
169
+ `unload()` first, so `reload([a,b])` is all-or-nothing — adding a third model reloads the first two
170
+ (~51 s each). Additive residency does not exist upstream.
171
+ - **"No load time is measured in the repo."** It is: 51 s, [AI.md](AI.md) line 77. A grep for the
172
+ wrong phrasing missed the table row.
173
+
174
+
175
+ ## `npm run e2e` verified the extraction on real hardware
176
+
177
+ First run against the post-extraction tree: real Firefox, real GPU (Apple Silicon, `shader-f16`),
178
+ real `Qwen3.5-0.8B-q4f16_1-MLC`, drag-and-drop ingestion through the production `src/engine/` and
179
+ `src/adapters/webext.js` paths. **`e2e PASS`.**
180
+
181
+ - Ingest: 443,129,354 bytes, 11 shards, 2531 ms. Load: 48.1 s (AI.md's 51 s figure is for the larger
182
+ 2B; the 0.8B here loading faster is consistent with that being memory-bandwidth-bound).
183
+ - Decode: 27.4 tok/s over 127 tokens — inside AI.md's measured 16.6–27.9 tok/s range for this model.
184
+ Decode probe: 664 kernels/token (639 forward + 25 sampling), 16.1 flushes/token — the same shape
185
+ the compute-pass-batching patch targets, and it is still applying (41.3 kernels/flush).
186
+ - KV-reuse path exercised and correct: paged prefill and forced ragged re-prefill produced identical
187
+ output on a multi-round conversation. Re-prefill slope 2.29 ms/token, in the neighbourhood of
188
+ AI.md's 5.27 ms/token figure (different model, different history length — not a direct comparison).
189
+ - The scheduler's own two-tasks-two-engines check: 3.8 s concurrent vs 4.0 s sequential = **1.05x**,
190
+ consistent with AI.md's measured 1.06x. This is the number the "second engine buys isolation, not
191
+ throughput" framing rests on, now reconfirmed after the pool moved to `#pools: Map<modelId,
192
+ EnginePool>` — evidence the multi-model split did not regress the single-model scheduling behaviour
193
+ it was built on top of.
194
+
195
+ **One number worth a second look, not treated as a finding here:** this run reported
196
+ `storageBuffersPerStage=9`. It did not block anything — the KV-reuse path was exercised in the same
197
+ run and passed — but it is the exact threshold `probeDevice()`'s `NO_KV_REUSE` warning keys off, so a
198
+ future run reporting the same value is worth cross-checking against `device.test.mjs`'s assumptions
199
+ rather than assumed benign a second time.
200
+
201
+ > **[resolved] A second run reported 9 again, and the cross-check says 9 is the baseline, not an
202
+ > anomaly.** AI.md has said so all along — the Firefox Metal backend caps
203
+ > `maxStorageBuffersPerShaderStage` at 9, which is the entire reason the `storage-buffer-limit`
204
+ > patch exists. What the cross-check *did* surface is sharper than the original worry:
205
+ > `device.test.mjs` defines a healthy device as `storageBuffers = 10`, so the "good" default in the
206
+ > test matrix describes hardware nobody here has. On the reference M4, `probe.kvReuse` is
207
+ > **always** `false`, `engine-worker.js` forces `resetChat()` on every prefill, and
208
+ > **`batch_prefill_paged_kv_kernel` has therefore never executed on real hardware** — it is
209
+ > mock-tested only. It would first run on a >=10-buffer device, i.e. Chrome (ROADMAP, Gate B).
210
+ >
211
+ > That also made the e2e's own multiround check misleading: with reuse forced off, its "with KV
212
+ > reuse" and "forced reprefill" branches both ran the ragged kernel, so `identical` was guaranteed
213
+ > and the line `paged prefill is fine` claimed something the run had not tested. The check now
214
+ > reports `UNVERIFIED for paged prefill` on a sub-10-buffer device and still fails if two ragged
215
+ > re-prefills of the same history disagree.
216
+
217
+ The manifest.json restore left a diff — `restore()` round-trips the file through
218
+ `JSON.parse`/`stringify`, which turns `\uXXXX`-escaped em-dashes back into literal UTF-8. Cosmetic,
219
+ not a behaviour change, reverted with `git checkout`. Worth knowing before the next e2e run leaves the
220
+ same diff and it looks like something broke.
221
+
222
+ > **[fixed]** It did leave the same diff on the next run. The snapshot now only round-trips through
223
+ > JSON when the tree is *actually* dirty (a run killed mid-flight leaves the patch behind); a clean
224
+ > file is restored byte-for-byte. The cost of the old behaviour was not untidiness — it was training
225
+ > the reader to ignore a dirty tree after an e2e, which is precisely when a real diff matters.
226
+
227
+
228
+ ## Lossless WebLLM upgrade — a bump is minutes, not an afternoon
229
+
230
+ `@mlc-ai/web-llm` is pinned exactly because `build.mjs` rewrites the bundle by matching source text.
231
+ "Lossless" was never meant as "automatic" — it means a bump *fails at the right line* instead of
232
+ somewhere deep in a half-patched loader. The standing runbook is in
233
+ [WEBLLM-SURFACE.md](WEBLLM-SURFACE.md), "Upgrading"; this is why each piece exists.
234
+
235
+ Three guards, because there are three distinct ways an upgrade breaks us:
236
+
237
+ | drift | caught by | the failure it prevents |
238
+ | --- | --- | --- |
239
+ | **surface** — code moved or reformatted | `build/patches.mjs` verify-then-write | a patch anchor silently landing in the wrong place, or the build half-applying and reporting only the first miss |
240
+ | **semantic** — a symbol survives, its meaning changed | `test/webllm-contract.test.mjs` | an export deleted, a field renamed, an enum gaining a case — none of which throw |
241
+ | **behavioural** — every name and shape intact, output wrong | `npm run e2e` | a tvmjs refactor that changes numerics |
242
+
243
+ **Contract tests (2a).** Makes WEBLLM-SURFACE.md executable — every export and shape the project
244
+ depends on, asserted statically against the bundle, GPU-free, first in `npm test`. The
245
+ highest-value piece: it catches semantic drift, which the patches cannot see. Two guards beyond the
246
+ obvious list: the monkeypatch member list is *derived from `multistep.js`'s own source* so it cannot
247
+ go stale, and `model_lib` unguessability is asserted rather than assumed (if it became derivable,
248
+ the "do not guess" rule in the verb-consolidation section should be revisited). Each assertion class
249
+ was mutation-tested — which found a real bug: `bundle.includes(name)` still passes when
250
+ `processNextToken` becomes `processNextTokenV2`, since the old name stays a substring. Now
251
+ word-bounded.
252
+
253
+ **Patch self-check and fuzzy diagnostics (2b).** Patches moved to `build/patches.mjs` as data,
254
+ applied by a shared verifier. Every anchor is checked before anything is rewritten. `patch-manifest.json`
255
+ records the version the anchors last held against, so a bump announces `0.2.84 -> 0.2.85: verifying
256
+ 4 anchor(s)` rather than silently succeeding. Vanished identifiers are matched against survivors by
257
+ trigram overlap — simulated upstream renaming `requiredMaxStorageBuffersPerShaderStage`, the
258
+ diagnostic found the replacement at 85% similarity with its line. Ambiguity is a hard stop too: an
259
+ anchor matching 1995 sites refuses rather than rewriting one at random. Two corrections the
260
+ simulation forced: rank candidate lines by summed *rarity* not hit count (raw count returned
261
+ `const msg = {` — true and useless), and a rename needs a human to *approve* the new anchor, not to
262
+ *find* it.
263
+
264
+ **Structured patches (2c).** The corrected expectation held: AST parsing survives *formatting*
265
+ drift, not renames — an AST search by name fails exactly as a string match does. So the gain is
266
+ narrower than "structured = durable", and the work matched the correction rather than the original
267
+ proposal. `in: { enclosing }` scopes an anchor to the function a *sibling anchor* matched in — a
268
+ matched anchor, not a function name, so it adds no identifier upstream could rename. This removes the
269
+ false-failure class around `compute.end();`, a string generic enough that any unrelated new compute
270
+ pass in tvmjs failed the build. Anchors also match modulo whitespace and are word-bounded — the
271
+ latter not in the plan and found the same way 2a's bug was: `compute.end();` is a substring of
272
+ `precompute.end();`. The rebuilt bundle is byte-identical to the string-replacing applier's output.
273
+ `acorn` is a devDependency, ~565 KB unpacked (the original estimate was off 10x), never shipped.
274
+
275
+ **Runtime monkeypatch guard (2d).** `multistep.js` drives ~30 undocumented tvmjs internals; a rename
276
+ turns the fast path off *silently* — ~18 -> ~10 tok/s with nothing in the log. 2a covers the static
277
+ half. The runtime half: `PIPELINE_CONTRACT` + `missingPipelineMembers()`, checked against the live
278
+ pipeline at first decode (there is no pipeline at install time — the engine gets one per `reload()`),
279
+ verdict cached. Three buckets, because presence is not the failure that hurts: `calls` must be
280
+ callable (a rename throws — loud), `numbers` must be numbers (`x += 1` on an absent member creates a
281
+ property and the KV accounting drifts — silent), `reads` need only exist. Failure is loud once per
282
+ pipeline and posts `multiStepOff` to the host, which is otherwise indistinguishable from an idle
283
+ engine since `onBurst` is the only thing that reports stats. Found while building it: 2a's derivation
284
+ matched `\bpipeline\.` and missed members reached across a line break — the softmax at the heart of
285
+ the burst had no rename guard for as long as that test existed. Now whitespace-tolerant, comments
286
+ stripped first, and `PIPELINE_CONTRACT` is asserted to equal what the source actually reaches for.
287
+
288
+ **The flow, documented (2e).** Moved to WEBLLM-SURFACE.md so the doc you must revise on a bump is
289
+ the doc that tells you how.
290
+
291
+
292
+ ## Verb consolidation — the ergonomic layer
293
+
294
+ `chat.completions.create()` is the compatibility layer and never changes. Everything here is
295
+ *additional* — the verbs a developer reaches for when they are not porting WebLLM code.
296
+
297
+ **`load(src, opts)` — one polymorphic entry.** Absorbs `load` + `registerModel` +
298
+ `ingestModelFolder`. Dispatch is a pure, synchronous `classifySource()` in
299
+ [src/engine/sources.js](src/engine/sources.js), so all four shapes — prebuilt id, HF/hosted URL,
300
+ `{model, modelLib}`, folder off disk — are testable with no GPU and no store. `registerModel` and
301
+ `ingestModelFolder` stay exported unchanged; `load()` composes them.
302
+
303
+ Two dispatch rules were **dropped after measuring**, both because a wrong guess surfaces as a 404
304
+ deep inside the loader:
305
+
306
+ - **`modelLib` is never guessed.** `<base><id>-webgpu.wasm` matches **0 of 163** prebuilt models
307
+ (real names carry a `_cs1k`-style suffix, drop `-MLC`) and **0 of 163** host the lib on the
308
+ weights' origin (they live on `raw.githubusercontent.com`). A remote source without `modelLib`
309
+ fails in the classifier with that sentence, before any fetch.
310
+ - **`/resolve/main/` is not derived for HF URLs.** WebLLM's `cleanModelUrl` already appends it; doing
311
+ it ourselves double-applies. A test asserts the stored URL is byte-identical to what was passed.
312
+
313
+ The id *is* derived from the URL's last segment — safe where `modelLib` is not, because an id is a
314
+ key in our own registry, never a path anything fetches, so a wrong guess is visible immediately and
315
+ free. `{ id }` overrides. `defer: true` on a bare prebuilt id is an **error**, not a silent load —
316
+ that silent load is the trap the whole section exists to avoid. Unknown ids get near-match hints.
317
+
318
+ Six mutation tests. One false pass worth remembering: the near-match hint appears at **two** error
319
+ sites and `String.replace` mutated only the first, so a working guard looked untested — a mutation
320
+ that does not apply is indistinguishable from a guard that does not work. Two latent crashes fixed
321
+ on the way: `filesFromInput`/`filesFromDataTransfer` spread their argument, so an array-like-but-not-
322
+ iterable `FileList`/`DataTransferItemList` died with `fileList is not iterable` three frames from
323
+ the caller's drop handler. Both use `Array.from` now.
324
+
325
+ **`unload(id, level)` — two depths, not two verbs.** `UNLOAD_LEVEL` is `"vram"` (default: free
326
+ VRAM, keep cache + record) or `"cache"` (also delete bytes, keep record — the old `evict()`).
327
+ `remove()` keeps its own verb: it is the one that cannot be undone without re-supplying the source.
328
+ An unrecognised level is refused with an error pointing at `remove()`, because "forget this model"
329
+ is the reading someone will try to spell as a level and it is the destructive one.
330
+
331
+ **[settled] A bare `unload()` frees only the current model**, with `unloadAll()` explicit for the
332
+ rest. The plan had the bare call free *everything*; shipping that silently would trap anyone already
333
+ calling `unload()`. "Free everything" is the more destructive reading and should be asked for by
334
+ name. `#evictBytes()` was split out of `evict()` so `unload(id, "cache")` reaches the bytes without
335
+ re-entering the class for a pool just torn down.
336
+
337
+ **`environment()` — read-only report; `.measure()` on it.** Absorbs `probe` + `features` +
338
+ `estimateSpeed` for the *reporting* half. [src/engine/environment.js](src/engine/environment.js), a
339
+ callable `engine.environment` cached like `chat`. Three open questions were all resolved by one
340
+ decision — **split read from write**: `environment()` reports only, writes go through `configure()`,
341
+ and passing a setting to `environment()` is an *error naming `configure()`*, not a silent no-op.
342
+ Implicit read/write dispatch by argument shape is the opposite of foolproof — the "reject or
343
+ write-then-report?" question had no intuitive answer precisely because one function was doing two
344
+ jobs.
345
+
346
+ Every report line carries `severity` · `affects` · `cause` · `fix` · `operable`, with **`fix: null`
347
+ ⟺ `operable: false`** asserted for every line — hardware, build-time flags and browser settings
348
+ report a consequence with no remedy, which is still the difference between a bug report and an
349
+ informed decision. A **blocked device short-circuits the report**: "K=15 forward steps per GPU sync"
350
+ next to "no model can load" is true and useless. `configure()` grew `engineCount` because the report
351
+ advertises it as operable and a report naming a call that throws is worse than no report — it is
352
+ persisted, not hot, and `environment()` reports that gap rather than pretending. The `multiStepOff`
353
+ guard (§2d) finally has a consumer: a `degraded` line naming the missing internal, where before it
354
+ was posted by the worker and read by nothing.
355
+
356
+ Seven mutation tests. One real hole found: "`local` never fetches" was tested with a fetch counter,
357
+ but `load()` caches the model's size so `estimateSpeed()` short-circuits and *neither* scope fetches
358
+ after a load. The guarantee is structural — `local` never consults the model layer — and is tested
359
+ that way now.
360
+
361
+
362
+ ## Engine capability — prefetch, embeddings, recipes
363
+
364
+ **`prefetch(modelId)`** — [src/engine/prefetch.js](src/engine/prefetch.js). Downloads a model with
365
+ no engine and **no WebGPU at all**: an app can warm the cache before it knows whether the machine
366
+ can run the model. Resumes; a second call is free.
367
+
368
+ The hard part: fetching the artifacts ourselves means deriving their URLs — the `/resolve/main/`
369
+ rule the verb-consolidation work above refused to derive. That refusal still holds; it was about not
370
+ deriving a URL *WebLLM will derive again at load*, which double-applies. Here WebLLM is not in the
371
+ loop — we are the loader. What makes it safe is not trusting the derivation: a key off by one
372
+ character writes a cache the loader never reads, and prefetch would report success while the user
373
+ downloads the model twice. So every prefetch ends by asking **WebLLM's own `hasModelInCache`** —
374
+ which derives through the very function we mirror — and throws if it says no. The contract test also
375
+ pulls `cleanModelUrl` out of the bundle and *runs* it against ours on six URL shapes, so an upstream
376
+ scheme change fails a test, not a download. Seven mutation tests, all caught.
377
+
378
+ **Embeddings (`engine.embed()`)** — a `kind` field on the job and one branch in
379
+ [pool.js](src/engine/pool.js) `#start`. **One pool, not two**: priority, supersession, preemption
380
+ and one-task-one-engine are identical for both kinds; only the call at the far end differs. A second
381
+ pool would have duplicated the scheduler to change one line. `embed()` returns bare vectors,
382
+ `embedRaw()` keeps WebLLM's envelope. **Known limit:** a running embedding cannot be interrupted —
383
+ `interruptGenerate()` works by making a decode loop break out, and one forward pass has no loop, so
384
+ a cancel that lands after the job starts marks it cancelled without stopping it. Stated in the
385
+ JSDoc, the README and a `[known limit]` test rather than left to be discovered. Six mutations, five
386
+ caught; the sixth was *equivalent* — `#start` decides on an explicit `=== EMBEDDING` and any unknown
387
+ kind routes to chat either way.
388
+
389
+ **Recipes — `ask()`, `conversation()`, `ghostText()`** — [src/engine/recipes.js](src/engine/recipes.js),
390
+ also methods on the engine. Scope grew on request: one command for each of the three things apps
391
+ actually want. The scheduling shipped as specified — one stable `session`, `interactive`, short
392
+ `max_tokens`, debounce, `cancel()` on blur, stale contexts dropped — and **prompts stayed with the
393
+ caller**: `ghostText({ prompt })` is required with no default; `ask`/`conversation` carry the
394
+ caller's text through. The engine authors nothing.
395
+
396
+ The piece worth keeping: `suggest()` **resolves `null` when stale**. The engine already superseded
397
+ the work; what a caller still had to remember was not to *paint* the answer that came back anyway.
398
+ Returning `null` removes the choice — the difference between a policy and a wrapper.
399
+ `conversation()` bounds history at 12 exchanges, derived from AI.md's numbers: with no cross-turn KV
400
+ reuse every turn re-prefills at ~5.27 ms/token, so unbounded history is quadratic and a turn near
401
+ the limit waits ~22 s. `keep: Infinity` opts out.
402
+
403
+ Found and fixed: a **promise leak in the debounce**. A newer keystroke called `clearTimeout` on the
404
+ previous waiter, whose `await` then had nothing to resolve it — every superseded keystroke leaked a
405
+ promise that never settled, and `Promise.all` over a burst hung forever. A superseded waiter has to
406
+ be woken and told it lost, not merely disarmed. Twelve mutation tests; two initially passed — one
407
+ equivalent, one genuinely vacuous: `sent[0].session === sent[1].session` also holds when *neither*
408
+ has a session, which is exactly the regression it was meant to catch. Presence is asserted before
409
+ equality now.
410
+
411
+
412
+ ## Shipping 0.1.0 — installable, documented, on npm
413
+
414
+ The library was extracted, tested and complete, and served the project's goal — "make WebLLM easier
415
+ to use, foolproof to build on" — **for nobody**, because it was unpublished, undocumented as a whole
416
+ surface, and un-installable. This section is the gap between "the code is done" and "a developer can
417
+ `npm i` it and run four lines."
418
+
419
+ **Four-line target, met without an engine change.** `import` / `CreateScheduledEngine(id)` /
420
+ `engine.ask(prompt)` / read the string. Probing the shape found three things that stopped it being
421
+ *usable*:
422
+
423
+ 1. **Un-installable.** `vendor/web-llm.js` is a build product and is gitignored; there was only a
424
+ `prepublishOnly`, and npm runs **`prepare`** for a git dependency. So `npm i` 404'd (`"private":
425
+ true`) and a git dependency installed but could not run, failing with `GENERATION_FAILED: Cannot
426
+ find module .../vendor/web-llm.js` — wrong twice, since nothing had begun generating and the path
427
+ named is ours. Now `"prepare": "node build.mjs"`, `private` removed. Verified by deleting
428
+ `vendor/` and running `npm install` — it comes back.
429
+ 2. **Looked like a hung process.** No `initProgressCallback` meant zero output during a minutes-long
430
+ ~0.8 GB download. `CreateScheduledEngine` now distinguishes three states: `undefined` → a
431
+ throttled console reporter (1 line/second; 58 shard callbacks → 2 lines; the 100% report is never
432
+ dropped), `null` → explicit silence, a function → unchanged. **`new ScheduledEngine()` stays
433
+ silent** — a library core that logs is wrong in a worker, an extension background page, or a
434
+ test. This is the getting-started facade only.
435
+ 3. **The Vite worker-URL break.** Vite's dependency pre-bundler copies `new Worker(new
436
+ URL("./engine-worker.js", import.meta.url))` verbatim into `node_modules/.vite/deps/`, where the
437
+ sibling file does not exist — `vite dev` only, real (non-linked) install only, `vite build`
438
+ unaffected. `everything-webgpu/vite` ships a plugin (`optimizeDeps.exclude`, the manual
439
+ equivalent still documented). And if a consumer does neither, `load()` now fails with
440
+ `PACKAGE_INCOMPLETE` naming the fix, because `new Worker()` does not throw on a 404 — it fires one
441
+ `error` event and goes quiet, so the handshake is raced against it.
442
+
443
+ **`PACKAGE_INCOMPLETE` is one code for two causes** (`detail.cause` separates them). No caller
444
+ writes a different `catch` branch: both mean "your build is wrong, this app has not shipped," both
445
+ are fixed in config. A second code would grow the table a caller reads without giving them anything
446
+ to do.
447
+
448
+ **`verify-consumer` — the only test that can see the consumer's world.** Everything under `test/`
449
+ and every `examples/` project reaches the package through a *linked* path, and Vite never
450
+ pre-bundles a linked package — so none of them can exercise the one failure that reaches users. This
451
+ blind spot produced a **wrong claim in the docs**: that `optimizeDeps.exclude` was needed for `vite
452
+ build` and that the examples proved it. Measured on a real tarball install, neither holds — build
453
+ output is byte-identical with and without it. `npm run verify-consumer` packs the tarball, installs
454
+ it for real, and asserts three outcomes separately: `vite build` emits the worker chunk and keeps
455
+ WebLLM lazy; `vite dev` **without** the plugin still 404s the worker; `vite dev` with it resolves.
456
+ The middle one is asserted as a *failure* on purpose — a fix whose absence changes nothing is not a
457
+ fix, and if Vite ever stops pre-bundling this package that assertion says the plugin is dead weight.
458
+
459
+ **`API.md` — every call form on one page,** asserted by
460
+ [test/api-doc.test.mjs](test/api-doc.test.mjs), derived from the source the way `readme.test.mjs`
461
+ is: every `engine.x(` named resolves to a real member, **no public member is left undocumented**
462
+ (the reverse direction the README test lacks), the error table equals `ERROR`, every export
463
+ appears, enum-value rows match the real objects, the subpath table equals `package.json` `exports`.
464
+ Writing it found `engine.store` undocumented and a regex reading enum rows as error codes.
465
+
466
+ **`examples/` — bare, react, webext,** each a standalone project depending on the package as
467
+ `file:../..` so it resolves through the **`exports` map** — an example importing
468
+ `../../src/engine/index.js` would still run and would still leave the exports map, the `files` list
469
+ and every entry path untested. [test/examples.test.mjs](test/examples.test.mjs) derives its checks
470
+ from the example sources, so a fourth example is covered the moment its directory exists. Also
471
+ closed a silent `files`/`exports` gap: a new export path that `files` would not publish resolves in
472
+ the checkout and 404s in the tarball. Asserted from `package.json` now.
473
+
474
+ **Bundle-size story, measured not estimated:** **53 kB (~19 kB gzip)** entry chunk before a model
475
+ loads; the 6 MB WebLLM bundle is a **lazy chunk** fetched on the first `load()` or
476
+ `listAvailableModels()` and never by a visitor who does neither; the IndexedDB adapter is a further
477
+ 0.8 kB lazy chunk that vanishes when a host brings its own store — the webext build emits no `idb`
478
+ chunk at all, which is that claim tested by construction.
479
+
480
+ **Licence compliance.** Publishing `vendor/web-llm.js` redistributes WebLLM (Apache-2.0) and its
481
+ dependency `loglevel` (MIT), and the esbuild bundle was built `legalComments: "none"` — no notice
482
+ survived, a violation. `THIRD-PARTY-NOTICES.md` now carries the full texts, generated from the
483
+ installed packages; [test/license.test.mjs](test/license.test.mjs) fails the build if a bundled
484
+ dependency ever lacks a notice, catching a future web-llm bump that inlines a new dep. `build.mjs`
485
+ uses `legalComments: "eof"` now — upstream has already stripped every `@license` banner (the bundle
486
+ is byte-identical either way today), but `"none"` would silently drop one a future dep adds. `LICENSE`
487
+ added (ISC). `files` scopes `vendor` to `web-llm.js` — the stale, unreferenced `vendor/web-llm.d.ts`
488
+ was shipping and made the tarball depend on disk state.
489
+
490
+ **Published:** `everything-webgpu@0.1.0`, 46 files, 2.3 MB packed; `dist.integrity` matched the
491
+ dry-run exactly. Still deferred to a later version: the `demo` extension rebuilding on the package
492
+ (the source-tree acceptance test for the extraction), and a `repository` field once the repo has a
493
+ remote.
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ISC License
2
+
3
+ Copyright (c) 2026 nyaaorick
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16
+
17
+ ---
18
+
19
+ This distribution includes a bundled build of WebLLM (@mlc-ai/web-llm,
20
+ Apache-2.0) at vendor/web-llm.js and its transitive dependency loglevel
21
+ (MIT). Their license texts are in THIRD-PARTY-NOTICES.md.
MLC-COMPILE.md ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Qwen3.8-2B-Distill → MLC/WebGPU: built, and what it cost
2
+
3
+ **Status: done.** `dist/Qwen3.8-2B-q4f16_1-MLC/` loads in Firefox and passes `npm run e2e`.
4
+ Everything labelled **measured** below was verified on this machine (M4 MacBook Air, 16 GB,
5
+ Firefox 154, macOS 26.5.2) during the session that built it.
6
+
7
+ The compile itself was the easy part. The toolchain was not: **no published pair of MLC nightlies
8
+ works together**, and the fix is not a version pin. See "The toolchain does not ship working" —
9
+ read it before touching the venv, because a fresh `pip install` reintroduces every one of these.
10
+
11
+ ## What was produced
12
+
13
+ | | |
14
+ | --- | --- |
15
+ | weights | 26 shards, **1.059 GB** on disk (1,089,449,808 bytes materialized) |
16
+ | params | **1,881,825,088** at **4.503 bits/param** (0.987 GB post-quantization) |
17
+ | library | `Qwen3.8-2B-q4f16_1_cs1k-webgpu.wasm`, 6,889,261 bytes, 154 kernels |
18
+ | config | q4f16_1, context 4096, prefill chunk 1024, conv template `qwen3_5` |
19
+
20
+ Reproduce with `tools/setup-mlc-toolchain.sh`, then:
21
+
22
+ ```sh
23
+ python3 tools/strip-vision.py --keep-prefix # 632 -> 320 tensors, 1.88B params
24
+ .venv-mlc/bin/python -m mlc_llm gen_config model/Qwen3.8-2B-text \
25
+ --quantization q4f16_1 --conv-template qwen3_5 \
26
+ --context-window-size 4096 --prefill-chunk-size 1024 -o dist/Qwen3.8-2B-q4f16_1-MLC
27
+ .venv-mlc/bin/python -m mlc_llm convert_weight model/Qwen3.8-2B-text \
28
+ --quantization q4f16_1 --device cpu -o dist/Qwen3.8-2B-q4f16_1-MLC
29
+ . ~/emsdk/emsdk_env.sh
30
+ export TVM_HOME=~/.cache/mlc-web-build/tvm
31
+ export MLC_LLM_SOURCE_DIR="$PWD/.venv-mlc/lib/python3.12/site-packages/mlc_llm"
32
+ .venv-mlc/bin/python -m mlc_llm compile dist/Qwen3.8-2B-q4f16_1-MLC/mlc-chat-config.json \
33
+ --device webgpu -o dist/Qwen3.8-2B-q4f16_1-MLC/Qwen3.8-2B-q4f16_1_cs1k-webgpu.wasm
34
+ ```
35
+
36
+ `--device cpu` on `convert_weight` is not a preference. **Measured:** `--device metal` dies in
37
+ TVM's LLVM ORC JIT with `Unexpected definitions in module TVMMod-jitted-objectbuffer` on the first
38
+ quantize kernel. The CPU path quantizes all 1.88 B params in 7.4 s, so there is nothing to miss.
39
+
40
+ ## Measured performance
41
+
42
+ ```
43
+ decode 16.9 tok/s over 127 tokens (projection was 14-18 — held)
44
+ prefill 47.9 tok/s, ttft 0.71 s
45
+ load ready in 51.1 s (ingest 4.8 s of that)
46
+ decode probe 664 kernels/tok = 639 forward + 25 sampling
47
+ cpu-encode 3.9 ms/tok, gpu+tick 62.7 ms/tok
48
+ 16.1 flushes/tok -> 41.3 kernels per flush
49
+ batch 4 prompts, 96 tok in 9.7 s = 9.9 tok/s aggregate at pool size 1
50
+ ```
51
+
52
+ `639 forward kernels ÷ 24 layers = 26.6`, i.e. the same ~27 the 0.8B showed. dlight made the same
53
+ call here — more reduction threads over more work per thread — so the previously measured **1.83x
54
+ from going 2 -> 32 iters/thread is still on the table**, and the toolchain is now up to try it.
55
+
56
+ ### The cost of having no KV reuse — now measured
57
+
58
+ This was the handoff's one explicitly-unmeasured risk ("measure it before assuming it is fine for
59
+ long chats"). It is not fine for long chats:
60
+
61
+ | history | wall | tok/s |
62
+ | --- | --- | --- |
63
+ | 29 tok | 624 ms | 71 |
64
+ | 249 tok | 2,604 ms | 104 |
65
+ | 915 tok | 9,251 ms | 101 |
66
+ | 2,249 tok | 12,318 ms | 186 |
67
+
68
+ **Slope: 5.27 ms per history token (190 tok/s marginal), floor 624 ms.** Every turn re-prefills the
69
+ whole conversation, so at the 4096-token context limit a late turn pays roughly `624 ms + 5.27 ms ×
70
+ 4000 ≈ 22 s` before its first token. Short chats are unaffected; long ones are not usable. If this
71
+ model is meant for long conversations, restoring KV reuse (see below) is no longer optional.
72
+
73
+ ## The storage-buffer goal: what actually happened
74
+
75
+ The aim was a build where nothing the engine dispatches binds more than 9 storage buffers, reached
76
+ **by deleting** dead kernels. Deletion turned out not to be available.
77
+
78
+ **Measured** — the 2B's histogram is identical to the verified 0.8B's, kernel for kernel:
79
+
80
+ | storage bindings | kernels |
81
+ | --- | --- |
82
+ | 1-6 | 146 |
83
+ | 8 | 1 |
84
+ | 9 | 3 |
85
+ | **10** | **4** |
86
+
87
+ The same four at 10: `batch_prefill_paged_kv_kernel`, `batch_prefill_paged_kv_sliding_window_kernel`,
88
+ `batch_tree_attn_kernel`, `tree_attn_paged_kv_kernel`.
89
+
90
+ **There is no flag, and there cannot be one.** In
91
+ `tvm/relax/frontend/nn/llm/kv_cache.py`, `_create_tir_paged_kv_cache` registers the sliding-window,
92
+ tree-attention and paged-prefill kernels unconditionally, as a **fixed positional tuple** handed to
93
+ the C++ `PagedKVCache` constructor. The runtime indexes that tuple by position, so omitting an entry
94
+ does not delete a kernel — it misaligns every kernel after it. This is the case the original handoff
95
+ anticipated ("if MLC emits the full set unconditionally, that is acceptable"), now confirmed at the
96
+ source rather than assumed.
97
+
98
+ So the position is unchanged from the 0.8B: three of the four are dead by config, and the live one,
99
+ `batch_prefill_paged_kv_kernel`, stays unreachable only because of the engine-side guard in
100
+ `src/background/engine-worker.js` (`resetChat()` before every prefill when
101
+ `maxStorageBuffersPerShaderStage < 10`). `tools/audit-wasm.mjs` still exits 1, correctly — it judges
102
+ the file, and it cannot see a guard that lives in the engine.
103
+
104
+ **Measured, and worth stating precisely:** the e2e's multi-round A/B came back *identical*. That
105
+ proves there is no silent garbage. It does **not** prove paged prefill works — the guard means both
106
+ branches of the A/B ran through the ragged kernel. The harness's own "paged prefill is fine" wording
107
+ overstates it.
108
+
109
+ ### If you want KV reuse back
110
+
111
+ Unchanged from before, and now motivated by the measured 5.27 ms/token slope. Do not re-fuse
112
+ anything: `batch_prefill_paged_kv_kernel` binds six `array<i32>` metadata buffers whose uniform block
113
+ already carries `*_elem_offset` for each, and the body already indexes as `name[expr + name_elem_offset]`.
114
+ Packing them into one buffer with six offsets is a signature change, not an algorithmic one: 10 -> 5.
115
+ Merging any two reaches 9. Grep `q_indptr` / `page_values` / `k_rope_pos_offset` in TVM — with the
116
+ toolchain now standing up, this is a tractable change rather than a research project.
117
+
118
+ ## The toolchain does not ship working
119
+
120
+ Five independent breakages, none of which is a version-pin problem. `tools/setup-mlc-toolchain.sh`
121
+ applies all of them; `tools/patch-mlc-nightly.py` and `tools/resign-venv.sh` are idempotent and must
122
+ be re-run after **any** `pip install` touching these packages.
123
+
124
+ **1. Every wheel dylib has an invalid signature.** `import tvm` is SIGKILLed by the kernel
125
+ (`CODESIGNING / Invalid Page`) inside `dlopen`. The ad-hoc signatures do not match the bytes.
126
+ `codesign --force --sign -` over each recomputes them. Silent — no Python traceback, just exit 137.
127
+
128
+ **2. `apache-tvm-ffi` must be pinned to exactly `0.1.13`.** The metadata says `>=0.1.13`, which
129
+ resolves to `0.1.13.post3`, whose ABI does not match the bundled runtime: it corrupts the heap in a
130
+ static initializer and aborts. (`0.1.13` is marked yanked on PyPI. It is still the one that works.)
131
+
132
+ **3. `psutil` is missing from the wheel's dependencies** but imported by `mlc_llm.serve`.
133
+
134
+ **4. The published nightlies are mutually incompatible — this is the big one.**
135
+ `mlc-llm-nightly-cpu 0.26.dev6` is written against an *unreleased* TVM in which `PrimFunc.params`
136
+ holds `Buffer`s directly and `tirx.is_buffer_var` exists. Every published `mlc-ai` wheel
137
+ (dev61/dev203/dev246) still has `params: Array<Var>` plus a separate `buffer_map` — verified in
138
+ `include/tvm/tirx/function.h` — and none defines `is_buffer_var`. Going the other way fails too:
139
+ `mlc-llm 0.20.dev162`'s Python *does* use `buffer_map`, but its dylib needs `TVMBackendParallelLaunch`,
140
+ which dev246 no longer exports.
141
+
142
+ dev6 is kept, because it has the newest `qwen3_5` model definition and the correct `qwen3_5`
143
+ conversation template. The places where it reaches for the unreleased API are rewritten against
144
+ `buffer_map` — **not invented**: each rewrite restores what `mlc-llm 0.20.dev162` does in that same
145
+ function, kept on dev6's relax spelling (`TensorType`/`ty_args`, where dev162 said
146
+ `TensorStructInfo`/`sinfo_args`). Affects `low_batch_specialization.py` and
147
+ `lift_global_buffer_alloc.py`.
148
+
149
+ Two more source-level fixes in the same script:
150
+
151
+ - **`nn/rnn_state.py` emits ill-formed TIR.** It binds the recurrent-state gather/scatter indices to
152
+ annotated locals (`seq_id: T.int32 = seq_slot_ids[vi]`); this TVM then infers a block read region
153
+ whose bound names a buffer `seq_id` that was never declared, and the **C++ verifier** rejects it —
154
+ so silencing the TVMScript parser's `check_well_formed` only defers the failure to a later pass.
155
+ Inlining each load at its single use site is the same computation and verifies clean. Qwen3.5 is
156
+ 75% GatedDeltaNet, so RNNState is on the critical path and cannot be skipped.
157
+ - **Every loader passes `mlc_param.dtype` into `numpy.astype`**, but here that is a
158
+ `tvm.ir.type.PrimType` (`T.float16`), not the `str` numpy needs. `str()` yields exactly `float16`.
159
+
160
+ **5. `--device webgpu` needs `web/dist/wasm/*.bc`, which the wheels neither ship nor can build.**
161
+ Their header tree is pruned to inconsistency (`include/tvm/` has only `runtime/`, yet
162
+ `runtime/tensor.h` includes the absent `tvm/support/io.h`). The runtime is therefore built from an
163
+ `apache/tvm` checkout pinned to **the commit the wheel records** (`tvm._version.__commit_id__`,
164
+ here `837cb9de1`) — the linked runtime must come from the same commit as the codegen that produced
165
+ the kernels. Two further wrinkles:
166
+
167
+ - **Build it outside a path containing spaces.** TVM's `web/Makefile` interpolates `TVM_ROOT` into
168
+ unquoted `-I` flags, so "Everything WebGPU" splits and clang fails on phantom directories. Hence
169
+ `~/.cache/mlc-web-build`.
170
+ - **`mlc_wasm_runtime.cc` is now only `#define`s** — its actual runtime moved into TVM's
171
+ `wasm_runtime.cc` — so it compiles standalone with no include path at all.
172
+
173
+ ### And the one that only shows up at runtime
174
+
175
+ TVM's `web/emcc/wasm_runtime.cc` pulls in tvm-ffi's sources one file at a time and **misses
176
+ `custom_allocator.cc`**. `TVMFFIGetCustomAllocator` is then left undefined and becomes a wasm
177
+ *import* — and no published `@mlc-ai/web-llm` supplies it (0.2.84 is latest; there is no newer
178
+ version to upgrade to). The model compiles, ships, ingests, and then dies at instantiation:
179
+
180
+ ```
181
+ FAIL: load: LinkError: import object field 'TVMFFIGetCustomAllocator' is not a Function
182
+ ```
183
+
184
+ Adding that one `#include` fixes it. The check that matters is not "does it compile" but **does the
185
+ import list match a known-good build** — compare against the reference wasm:
186
+
187
+ ```sh
188
+ node tools/wasm-imports.mjs dist/Qwen3.8-2B-q4f16_1-MLC/*.wasm \
189
+ ~/Downloads/Qwen3.5-0.8B-q4f16_1-MLC/*.wasm
190
+ ```
191
+
192
+ Pass a reference as the second argument and it exits non-zero on any import the reference does not
193
+ need. This build now asks for 10, all of them in the reference's 11 (it does not need `proc_exit`).
194
+ Anything *extra* means the runtime is newer than the JS glue, and it will fail only at load time.
195
+
196
+ TVM also links the final `.wasm` with `emcc`, which under emscripten 6.x no longer pulls in the C++
197
+ stdlib for bitcode inputs — `wasm-ld` fails on `std::cerr` and the `ostringstream` vtables. Patched
198
+ to `em++`, which is emscripten's own suggestion in the error.
199
+
200
+ ## Resolved unknowns from the original handoff
201
+
202
+ | question | answer |
203
+ | --- | --- |
204
+ | keep `model.language_model.` prefix or rewrite? | **Keep it.** `qwen35_loader.py` hardcodes `hf = "model.language_model"` and maps MLC `model.X` -> `model.language_model.X`. Use `--keep-prefix`. |
205
+ | is flattening `text_config` the step most likely to fail? | **No — it is handled upstream.** `Qwen35Config.__post_init__` already unwraps both `text_config` and nested `rope_parameters`. The strip script still flattens, which is equivalent. |
206
+ | flags to exclude tree-attn / sliding-window / paged-prefill? | **None exist**, and none can — fixed positional tuple, see above. |
207
+ | cost of no KV reuse? | **5.27 ms per history token**, floor 624 ms. Measured, and it matters. |
208
+ | does MLC support this architecture? | Yes — `qwen3_5` in the registry, `mlc_llm/model/qwen35/`, hybrid `kv_state_kind: 'hybrid'` (paged KV + RNN state). |
209
+
210
+ ### Two things the reference 0.8B gets wrong that this build does not
211
+
212
+ - **Stop tokens.** The reference was built with `--conv-template qwen2`, so its `stop_token_ids` are
213
+ `[151643, 151645]` — **Qwen2 ids, which do not exist in this 248320-token vocab**. It survives
214
+ only on `stop_str` text matching. This build uses `--conv-template qwen3_5`, whose template
215
+ already carries the correct `[248046, 248044]` (`<|im_end|>`, `<|endoftext|>`), and `gen_config`
216
+ independently picked the same pair out of `generation_config.json`.
217
+ - **Thinking.** `qwen3_5` opens the assistant turn with `<think>`, which is what this checkpoint
218
+ expects — the model card says every answer opens with a `<think>` block. Confirmed in the e2e
219
+ output. Use `qwen3_5_nothink` to suppress it.
220
+
221
+ ## Notes on the checkpoint
222
+
223
+ `empero-ai/Qwen3.8-2B-Distill`, at `model/Qwen3.8-2B-Distill/` (gitignored). **Measured** from the
224
+ safetensors header: 632 tensors, 2.27 B params, cleanly separated by prefix —
225
+ `model.language_model` (320 / 1.88 B, keep), `model.visual` (297 / 331 M, drop), `mtp` (15 / 61 M,
226
+ drop). The model card confirms the fine-tune is text-only with vision inherited from the base.
227
+
228
+ `tools/strip-vision.py` streams byte ranges out of the source safetensors rather than loading it, so
229
+ it runs in a few MB rather than the 4.5 GB the tensors occupy — worth keeping on a 16 GB machine.
230
+ It also flattens the config and hoists `rope_theta` / `partial_rotary_factor`, and folds both EOS
231
+ ids from `generation_config.json` into `eos_token_id`.
232
+
233
+ Architecture vs the verified 0.8B: only `hidden_size` (1024 -> 2048) and `intermediate_size`
234
+ (3584 -> 6144) differ. Layers 0-2 of each group of 4 are GatedDeltaNet linear attention
235
+ (`A_log`, `dt_bias`, `conv1d`, `in_proj_{qkv,a,b,z}`); every 4th is full attention with a gated
236
+ `q_proj` of `[4096, 2048]` = 2 x 8 heads x 256 (`attn_output_gate: true`).
237
+
238
+ **Keep `q4f16_1`.** Dequantisation is ~free (41 GB/s with unpack-and-scale vs 41-46 without), so a
239
+ wider format only adds bytes, and bytes are what decode pays for.
240
+
241
+ ### Sampling
242
+
243
+ The model card recommends `temperature=0.6, top_p=0.95, top_k=20`, and warns that greedy decoding is
244
+ a repetition-loop failure mode for this class. `gen_config` writes MLC's defaults (1.0 / 1.0), so
245
+ **this build overwrites two of the three** in `mlc-chat-config.json`:
246
+
247
+ | | value | effective? |
248
+ | --- | --- | --- |
249
+ | `temperature` | 0.6 | **yes** — the engine default was moved to match |
250
+ | `top_p` | 0.95 | **yes** — nothing overrides it |
251
+ | `top_k` | 20 | **no** — compiled in, but unreachable |
252
+
253
+ `gen_config` rewrites this file, so a rebuild resets both. Re-apply after it:
254
+
255
+ ```sh
256
+ python3 - <<'EOF'
257
+ import json, pathlib
258
+ p = pathlib.Path("dist/Qwen3.8-2B-q4f16_1-MLC/mlc-chat-config.json")
259
+ c = json.loads(p.read_text()); c["temperature"] = 0.6; c["top_p"] = 0.95
260
+ p.write_text(json.dumps(c, indent=2) + "\n")
261
+ EOF
262
+ ```
263
+
264
+ **`top_k` has nowhere to land.** The library genuinely supports it — `get_renorm_prob` takes
265
+ `top_k: Tensor([batch, 1], int32)` and the WGSL applies a real top-k cutoff beside top-p — but
266
+ `top_k` appears **zero** times in `vendor/web-llm.js`, `src/background/multistep.js` and
267
+ `src/background/engine-worker.js`. The burst sampler calls `fsampleWithTopP` with temperature and
268
+ top_p only. So it is the same shape of problem as the 10-buffer kernels: present in the file, dead
269
+ on the live path. Reaching it means wiring a `top_k` through *both* the multistep burst sampler and
270
+ the vendored WebLLM path — partial wiring would make burst and non-burst decoding sample
271
+ differently, which is worse than leaving it. The reference 0.8B is in exactly the same position.
272
+
273
+ **`temperature` needed a second change.** `src/background/background.js` sets
274
+ `temperature: payload.temperature ?? settings.temperature` on every request, so
275
+ `DEFAULT_SETTINGS.temperature` in `src/lib/model-store.js` shadows whatever the model ships —
276
+ setting `mlc-chat-config.json` alone would have done nothing. That default is now `0.6` to match.
277
+ It is a single global, not per-model, so it applies to any other model loaded in the extension.
278
+ `top_p` is never injected by `buildParams`, which is why 0.95 takes effect straight from the model
279
+ config.
280
+
281
+ ## Verify
282
+
283
+ ```sh
284
+ node tools/audit-wasm.mjs dist/Qwen3.8-2B-q4f16_1-MLC # exits 1: see the buffer section above
285
+ node tools/wasm-imports.mjs dist/Qwen3.8-2B-q4f16_1-MLC/*.wasm
286
+ MODEL_DIR="$PWD/dist/Qwen3.8-2B-q4f16_1-MLC" ENGINE_COUNT=1 npm run e2e
287
+ ```
288
+
289
+ The ingest contract (`src/lib/ingest.js`) is satisfied: `mlc-chat-config.json`, `tensor-cache.json`
290
+ with 26 `dataPath` records all present, `tokenizer.json` both present and listed in
291
+ `tokenizer_files`, and exactly one `.wasm`. `gen_config` also emits `tensor-cache-b16.json` (same 26
292
+ shards); it is ignored by ingest and harmless.
README.md ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: isc
3
+ library_name: everything-webgpu
4
+ pipeline_tag: text-generation
5
+ tags:
6
+ - webgpu
7
+ - mlc-ai
8
+ - web-llm
9
+ - browser
10
+ - on-device
11
+ - local-llm
12
+ - ghost-text
13
+ - scheduler
14
+ - typescript
15
+ - javascript
16
+ ---
17
+
18
+ # everything-webgpu
19
+
20
+ [![npm version](https://img.shields.io/npm/v/everything-webgpu.svg?color=cb3837)](https://www.npmjs.com/package/everything-webgpu) [![GitHub](https://img.shields.io/badge/GitHub-nyaaorick%2Feverything--webgpu-black?logo=github)](https://github.com/nyaaorick/everything-webgpu) [![WebGPU](https://img.shields.io/badge/Platform-WebGPU-orange.svg)](https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API) [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC)
21
+
22
+ Run local MLC/WebLLM models on WebGPU, in a browser, with a scheduler in front of them.
23
+
24
+ **Migrating off `@mlc-ai/web-llm` is one line.**
25
+
26
+ ```diff
27
+ -import { CreateMLCEngine } from "@mlc-ai/web-llm";
28
+ -const engine = await CreateMLCEngine(modelId, { initProgressCallback });
29
+ +import { CreateScheduledEngine } from "everything-webgpu";
30
+ +const engine = await CreateScheduledEngine(modelId, { initProgressCallback });
31
+ ```
32
+
33
+ Everything after that line is unchanged. `engine.chat.completions.create()` takes and returns the
34
+ same shapes, streams the same chunk objects, and reports the same finish reasons. That call is the
35
+ compatibility layer and **it never changes** — see [Stability](#stability).
36
+
37
+ What the swap buys:
38
+
39
+ | | |
40
+ | --- | --- |
41
+ | **A scheduler** | `session`, `priority`, `task`, `preemptible` on any request. One engine per task, pool grows on demand, stale requests supersede themselves. |
42
+ | **Multi-step decoding** | K forward steps per GPU sync. On the 0.8B baseline: 9.6 → 25.9 tok/s. |
43
+ | **Two build-time patches** | Firefox's 9-storage-buffer cap, and one compute pass per flush instead of per kernel. |
44
+ | **Three model sources** | A prebuilt id, a URL you host, or **a folder off disk with no network at any point**. |
45
+ | **Typed errors** | Eight codes, each one because a caller does something different about it. |
46
+
47
+ ---
48
+
49
+ ## Status
50
+
51
+ **On npm** — `npm i everything-webgpu`. Pre-1.0, ESM only (`"type": "module"`).
52
+
53
+ ```sh
54
+ npm i everything-webgpu
55
+ ```
56
+
57
+ `vendor/web-llm.js` — the patched WebLLM bundle the engine loads — is a **build product and is not
58
+ checked in**, so every install route has to produce it. The published tarball ships it prebuilt;
59
+ `prepare` regenerates it for a git dependency or a clone. If it is ever missing you get
60
+ `PACKAGE_INCOMPLETE` naming the fix, rather than a module-resolution error pointing inside this
61
+ package.
62
+
63
+ Requires WebGPU. Verified end to end on Firefox 154 / macOS / M4; on Firefox you may need to set
64
+ `dom.webgpu.enabled` and the related flags first — [AI.md](AI.md) lists them and says what each one
65
+ does. Chrome is expected to be faster (it does not hit the storage-buffer cap that disables KV
66
+ reuse) but that is a prediction, not yet a measurement.
67
+
68
+ ## Quick start — four lines
69
+
70
+ ```js
71
+ import { CreateScheduledEngine } from "everything-webgpu"; // 1. import
72
+ const engine = await CreateScheduledEngine("Llama-3.2-1B-Instruct-q4f16_1-MLC"); // 2. load a model
73
+ const reply = await engine.ask("Name three primary colours."); // 3. ask
74
+ console.log(reply); // 4. the answer
75
+ ```
76
+
77
+ `reply` is a plain string. Line 2 downloads ~0.8 GB the first time and prints throttled progress to
78
+ the console; pass `initProgressCallback` to render it yourself, or `initProgressCallback: null` to
79
+ silence it. On Vite, add one plugin — see [API.md § Bundlers](API.md#bundlers). Every other call
80
+ form — `conversation()`, `load()` from a URL or off disk, streaming, embeddings, the OpenAI shape —
81
+ is catalogued in **[API.md](API.md)**.
82
+
83
+ The WebLLM-compatible shape, unchanged:
84
+
85
+ ```js
86
+ const reply = await engine.chat.completions.create({
87
+ messages: [{ role: "user", content: "hi" }],
88
+ session: "ghost-text", // added by this engine
89
+ priority: "interactive", // added by this engine
90
+ });
91
+ console.log(reply.choices[0].message.content);
92
+ ```
93
+
94
+ Streaming is WebLLM's, passed through untouched:
95
+
96
+ ```js
97
+ const stream = await engine.chat.completions.create({ messages, stream: true });
98
+ for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
99
+ ```
100
+
101
+ The first `load()` downloads the weights; after that it is cache-only and needs no network.
102
+
103
+ ### Runnable
104
+
105
+ [examples/](examples/) has three, each a standalone project that consumes this package through its
106
+ `exports` map rather than reaching into the source tree:
107
+
108
+ ```sh
109
+ cd examples/bare && npm install && npm run dev
110
+ ```
111
+
112
+ | | |
113
+ | --- | --- |
114
+ | [bare/](examples/bare/) | Vanilla Vite, one page. ~100 lines. |
115
+ | [react/](examples/react/) | A chat UI over `conversation()`. |
116
+ | [webext/](examples/webext/) | MV2 Firefox — `browser.storage.local` as the store, and the CSP a wasm runtime needs. |
117
+
118
+ ## What it costs your bundle
119
+
120
+ **53 kB** (~19 kB gzip) in the entry chunk, before a model is loaded. WebLLM's 6 MB bundle is a
121
+ **lazy chunk** — fetched on the first `load()` or `listAvailableModels()`, and never by a visitor
122
+ who does neither, so a page can decide whether this machine has a GPU before paying for it. The
123
+ IndexedDB adapter is another 0.8 kB lazy chunk, absent entirely if you pass your own store.
124
+
125
+ On Vite, add the plugin this package ships:
126
+
127
+ ```js
128
+ import { everythingWebGPU } from "everything-webgpu/vite";
129
+ export default defineConfig({ plugins: [everythingWebGPU()] });
130
+ ```
131
+
132
+ It exists for one reason: Vite's dependency pre-bundler otherwise rewrites the
133
+ decode worker's URL to a path that 404s — in `vite dev` only, and only on a real
134
+ install, which is why no example in this repo can catch it and
135
+ `npm run verify-consumer` exists instead. Skip it and `load()` fails with
136
+ `PACKAGE_INCOMPLETE` naming the fix, rather than hanging.
137
+
138
+ ## Three ready-made shapes
139
+
140
+ Most apps want one of three things. Each is one call, with the scheduling already right:
141
+
142
+ ```js
143
+ // 1. One question, nothing kept.
144
+ const answer = await engine.ask("Summarise this in one line:\n" + doc);
145
+
146
+ // 2. A conversation that remembers.
147
+ const chat = engine.conversation({ system: "You are terse." });
148
+ await chat.say("what's the capital of France?");
149
+ await chat.say("and its population?"); // remembers
150
+
151
+ // 3. Ghost text, debounced and superseding.
152
+ const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` });
153
+ editor.on("input", async () => {
154
+ const hint = await ghost.suggest(editor.textBefore());
155
+ if (hint !== null) render(hint); // null = a newer keystroke won
156
+ });
157
+ editor.on("blur", () => ghost.cancel());
158
+ ```
159
+
160
+ `complete()` can express all three. These exist because the **scheduling** is the part that's easy
161
+ to get wrong and invisible when you do — every row of [Getting these wrong](#getting-these-wrong) is
162
+ a scheduling mistake, not a generation one. So each verb is a policy:
163
+
164
+ | | policy |
165
+ | --- | --- |
166
+ | `ask()` | its own task, no session — two in flight never supersede each other |
167
+ | `conversation()` | one stable task for every turn, turns serialised, history bounded |
168
+ | `ghostText()` | debounce + one session key + `interactive` + **resolves `null` when stale** |
169
+
170
+ That last one is the difference between a verb and a wrapper. The engine already drops superseded
171
+ work; what a caller still had to remember was not to *paint* the answer that came back anyway.
172
+ Returning `null` removes the choice.
173
+
174
+ **They author no prompts.** `ask()` and `conversation()` carry your text through; `ghostText()`
175
+ *requires* a `prompt` function and has no default. Prompts are model-specific — switching this
176
+ project's own build from a 0.8B to a 2B changed the conversation template and made every reply open
177
+ with a `<think>` block. A prompt that lives in your code survives that.
178
+
179
+ `conversation()` bounds history at 12 exchanges by default, because there is no cross-turn KV reuse
180
+ here: every turn re-prefills the whole history at ~5.27 ms/token, so an unbounded conversation gets
181
+ quadratically slower and a turn near the context limit waits ~22 s for its first token. Pass
182
+ `keep: Infinity` to opt out, having read that sentence.
183
+
184
+ ## Where models come from
185
+
186
+ **One call covers all three.** `load()` works out what you handed it, registers whatever needs
187
+ registering, and brings the model up:
188
+
189
+ ```js
190
+ // 1. Prebuilt — one of WebLLM's 163 HuggingFace models, or anything you registered earlier.
191
+ await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC");
192
+
193
+ // 2. Remote — any base URL you host. `modelLib` is required; see below.
194
+ await engine.load("https://cdn.example/models/my-model/", {
195
+ modelLib: "https://cdn.example/models/my-model/my-model-webgpu.wasm",
196
+ });
197
+ await engine.load({ model: "/models/my-model/", modelLib: "/models/my-model/lib.wasm" });
198
+
199
+ // 3. Injected — a folder off disk. No network at any point.
200
+ await engine.load(dropEvent.dataTransfer); // a drop
201
+ await engine.load(input.files); // <input webkitdirectory>
202
+ await engine.load({ files }); // already-unpacked { path, file }[]
203
+ ```
204
+
205
+ The model id is derived from the URL's last segment; pass `{ id }` to override it. Add
206
+ `{ defer: true }` to register a source **without** building a pool — it returns the registry record,
207
+ for a drop-now-load-later flow. `registerModel()` and `ingestModelFolder()` remain as the low-level
208
+ primitives; `load()` composes them rather than replacing them.
209
+
210
+ `listAvailableModels()` enumerates all three routes. Construct the engine with `{ prebuilt: false }`
211
+ for a build that must never fetch a model over the network. A misspelled id is told what it might
212
+ have meant.
213
+
214
+ ### Warming the cache early
215
+
216
+ ```js
217
+ await engine.prefetch(modelId, { onProgress: (p) => bar(p.done / p.total) });
218
+ ```
219
+
220
+ Downloads the model **without building an engine and without WebGPU** — so an app can warm the cache
221
+ while the user is still reading the welcome screen, before it has decided whether this machine can
222
+ run the model at all. WebLLM cannot express this: `reload()` instantiates the wasm and needs a GPU
223
+ before it fetches a single shard. A later `load()` is then a cache read. Interrupted downloads
224
+ resume, and a second call is free.
225
+
226
+ ## Embeddings
227
+
228
+ ```js
229
+ await engine.load("snowflake-arctic-embed-s-q0f32-MLC", { keepResident: true });
230
+
231
+ const [q] = await engine.embed("how do I cancel a job?");
232
+ const docs = await engine.embed(paragraphs); // one vector per input, in order
233
+ ```
234
+
235
+ Embedding models are separate models — WebLLM ships four `snowflake-arctic-embed-*` from 239 MB — so
236
+ this usually names `modelId` and holds it resident alongside a chat model. Embeddings go through the
237
+ **same scheduler** as completions: same priority bands, same `session` supersession, same
238
+ one-task-one-engine rule. `embedRaw()` returns WebLLM's OpenAI-shaped envelope if you are porting
239
+ code that expects `data[].embedding`.
240
+
241
+ One difference worth knowing: **a running embedding cannot be interrupted.** Cancellation works by
242
+ making a decode loop break out, and one forward pass has no loop — so a `cancel()` landing after the
243
+ job starts marks it cancelled without stopping it. Queued embeddings supersede normally.
244
+
245
+ **`modelLib` is required for a remote source and is not guessed.** It is genuinely underivable:
246
+ across all 163 prebuilt models, zero have a lib name derivable from the model id, and zero host the
247
+ lib on the same origin as the weights. A guess would be wrong every time and would surface as a
248
+ confusing 404 deep inside the loader, so the engine asks instead.
249
+
250
+ ## The three shapes of work
251
+
252
+ What differs between these is *not* the call or the transport. It is **who owns an engine, and what
253
+ may interrupt what.**
254
+
255
+ | | call | priority | key field | why |
256
+ | --- | --- | --- | --- | --- |
257
+ | **Completion** (ghost text) | `complete` streaming | `interactive` | `session` | Each keystroke supersedes the last request; may preempt opted-in work. |
258
+ | **Translation** (a page) | `batch` | `normal` | one shared `task` | One request instead of N, so the engine schedules it as a unit and it never hogs the pool. |
259
+ | **Reformat** (markdown) | `complete` | `background` | `preemptible: true` | Nobody is watching; let interactive work cut in. |
260
+
261
+ ### Completion — latency is the whole product
262
+
263
+ ```js
264
+ // On every keystroke. The previous request is cancelled, not queued behind.
265
+ const { text } = await engine.complete(
266
+ {
267
+ messages: [{ role: "user", content: prefix }],
268
+ session: "ghost-text", // supersession key — the important field
269
+ priority: "interactive", // may preempt jobs that opted in
270
+ max_tokens: 24, // ghost text is short; do not pay for more
271
+ },
272
+ (delta) => render(delta),
273
+ );
274
+ ```
275
+
276
+ **`session` is what makes this work, not `cancel`.** Reusing one session key means the engine drops
277
+ the stale request itself. A caller that mints a fresh id per keystroke and calls `cancel` races its
278
+ own typing.
279
+
280
+ ### Translation — throughput, one task
281
+
282
+ ```js
283
+ // One batch, not a loop of `complete` calls.
284
+ const results = await engine.batch({
285
+ task: "translate-page", // optional; a batch is one task either way
286
+ requests: sentences.map((s) => ({
287
+ messages: [{ role: "user", content: `Translate to French, output only the translation:\n${s}` }],
288
+ })),
289
+ });
290
+ results.forEach((r) => apply(r.index, r.text));
291
+ ```
292
+
293
+ Every item of one batch shares a task, and a task holds one engine, so a 200-sentence page occupies
294
+ exactly one engine and can never freeze ghost-text behind it. Items carry `engineIndex`, `startedAt`
295
+ and `finishedAt`, so you can check what actually ran where.
296
+
297
+ `batch` stays the right call rather than a loop: it is one scheduling unit the engine can reason
298
+ about, and when batched decode lands the same call gets faster with no change on your side.
299
+
300
+ ### Reformat — cheap to interrupt
301
+
302
+ ```js
303
+ await engine.complete({
304
+ messages: [{ role: "user", content: `Reformat as clean Markdown, no commentary:\n\n${doc}` }],
305
+ priority: "background",
306
+ preemptible: true, // the direction matters — see below
307
+ max_tokens: 2048,
308
+ });
309
+ ```
310
+
311
+ **Set `preemptible` on the work that can afford to lose, not on the work you care about.** Only an
312
+ `interactive` request preempts, and only a job that opted in can be preempted. A preempted job
313
+ resolves with `preempted: true` and whatever text it had — never requeued, so it can never starve,
314
+ but you must be able to use or discard a partial result.
315
+
316
+ ## Getting these wrong
317
+
318
+ | symptom | cause |
319
+ | --- | --- |
320
+ | Ghost text lags behind typing | Fresh `id` per keystroke with no `session`, so every stale request still runs. |
321
+ | Page translation is slower than expected | Expected: one task is one engine, and a second engine measured 1.06x anyway. Throughput here comes from batched decode, not from more engines. |
322
+ | Reformatting blocks completions | `preemptible` left off the background job, so `interactive` has nothing to take. |
323
+ | Pool stays at one engine | Expected: it grows only when a *second task* waits. Check `engine.state.pool.growthBlocked` if two are waiting and it still has not. |
324
+
325
+ ## Prompts stay with you
326
+
327
+ The engine does not author prompts. They are model-specific — switching one build from
328
+ `Qwen3.5-0.8B` to `Qwen3.8-2B-Distill` changed the conversation template and made every reply open
329
+ with a `<think>` block. A prompt that lives in the caller survives that; a `translate` op baked into
330
+ the engine would have to be rewritten and re-shipped to every caller.
331
+
332
+ ## When it does not work
333
+
334
+ Every failure carries a code. `message` stays human-readable and stays the thing you print; `detail`
335
+ carries structured context so you never parse the sentence.
336
+
337
+ ```js
338
+ import { isEngineError, ERROR } from "everything-webgpu";
339
+
340
+ try {
341
+ await engine.load(id);
342
+ } catch (err) {
343
+ if (isEngineError(err, ERROR.CACHE_INCOMPLETE)) return reRegisterFolder();
344
+ throw err;
345
+ }
346
+ ```
347
+
348
+ | code | what to do about it |
349
+ | --- | --- |
350
+ | `NO_WEBGPU` | Tell the user to check flags/hardware; retrying is futile. |
351
+ | `NO_MODEL` | Nothing registered at all — send them to your setup flow. |
352
+ | `UNKNOWN_MODEL` | That id is not resolvable; `listAvailableModels()` says what is. |
353
+ | `CACHE_INCOMPLETE` | A locally-registered model was evicted; re-register the folder. |
354
+ | `INVALID_MODEL_FOLDER` | Not a compiled MLC model; `detail` says what is missing. |
355
+ | `BAD_REQUEST` | The caller's arguments are wrong — a bug in the caller. |
356
+ | `ABORTED` | The caller cancelled it. Not a failure; do not report it as one. |
357
+ | `GENERATION_FAILED` | The model failed mid-generation. |
358
+ | `PACKAGE_INCOMPLETE` | Your **build** is wrong, not your code — a missing `vendor/` bundle or a decode worker the bundler did not emit. `message` names the fix; `detail.cause` says which. |
359
+
360
+ ### Ask before you download, not after
361
+
362
+ `environment()` is the preflight: one call that says what this machine will do, and why.
363
+
364
+ ```js
365
+ const report = await engine.environment();
366
+ if (!report.ok) console.error(report.lines[0].fix ?? report.lines[0].cause);
367
+ ```
368
+
369
+ Every line carries the same five fields, so you can render the whole report without special-casing
370
+ any of it:
371
+
372
+ | field | |
373
+ | --- | --- |
374
+ | `severity` | `blocked` · `degraded` · `tune` · `info` · `ok` — lines come sorted worst-first |
375
+ | `affects` | what you lose, in your terms |
376
+ | `cause` | the measured fact behind the verdict |
377
+ | `fix` | the exact call to make, or `null` when nothing can be done |
378
+ | `operable` | whether this is reachable from JS at all |
379
+
380
+ `fix` is `null` for hardware, build-time flags, and browser settings JS cannot reach. Reporting a
381
+ consequence with no remedy is still the point: *"your second turn is slow because this device caps
382
+ storage buffers at 9"* is the difference between a bug report and an informed decision.
383
+
384
+ ```js
385
+ await engine.environment({ scope: "local" }); // never touches the model layer — cheap, poll freely
386
+ await engine.environment({ scope: "device" }); // hardware only
387
+ await engine.environment.measure(); // one calibration generation → measured tok/s
388
+ ```
389
+
390
+ **`environment()` only reports.** Writes go through `configure()`, and passing a setting to
391
+ `environment()` is an error that names the call you wanted — one function doing both, told apart by
392
+ argument shape, is how you get a call that silently does nothing.
393
+
394
+ ```js
395
+ await engine.configure({ decodeSteps: 8 }); // hot, no reload
396
+ await engine.configure({ engineCount: 3 }); // persisted; applies to pools built after it
397
+ ```
398
+
399
+ `canRun(modelId)` stays separate and answers the per-**model** question — will this one fit and run
400
+ here — where `environment()` is per-**device**.
401
+
402
+ ## Freeing memory
403
+
404
+ A resident model is a full copy of its weights in VRAM, and nothing reports free VRAM to a page — so
405
+ residency is explicit rather than guessed at.
406
+
407
+ ```js
408
+ await engine.unload(); // the current model's VRAM; cached bytes stay, so reloading is free
409
+ await engine.unload(id); // that model's VRAM
410
+ await engine.unload(id, "cache"); // and delete its cached bytes, keeping the registry entry
411
+ await engine.unloadAll(); // every resident model
412
+ await engine.remove(id); // bytes and entry; for an injected model, means re-supplying the folder
413
+ ```
414
+
415
+ A bare `unload()` frees **only the current model** — `unloadAll()` is the explicit form, because
416
+ freeing everything is the more destructive reading and should have to be asked for by name.
417
+ `remove()` keeps its own verb for the same reason: it is the one that cannot be undone.
418
+ `evict(id)` remains as the low-level primitive `unload(id, "cache")` is built on.
419
+
420
+ `load()` unloads whatever else is up before bringing a model in, which is the safe default on a
421
+ 16 GB machine. Pass `{ keepResident: true }` to hold two at once, having checked the budget with
422
+ `canRun()` first. `cacheState(modelId)` says what is already on disk.
423
+
424
+ ## Choosing a store
425
+
426
+ `CreateScheduledEngine` defaults to IndexedDB, because a registry that dies with the page would
427
+ strand the weights — the bytes stay in Cache Storage but nothing remembers they are there. Pass one
428
+ explicitly in a worker or a test:
429
+
430
+ ```js
431
+ import { ScheduledEngine, ModelStore } from "everything-webgpu";
432
+ import { memoryStorage } from "everything-webgpu/adapters/memory";
433
+
434
+ const engine = new ScheduledEngine({ store: new ModelStore(memoryStorage()) });
435
+ ```
436
+
437
+ Adapters ship for `idb` (pages, plus `ensurePersistent()`), `memory` (tests), and `webext`
438
+ (`browser.storage.local` and the message/port router).
439
+
440
+ ## Measured
441
+
442
+ M4 MacBook Air (16 GB), Firefox 154 release, macOS. `Qwen3.8-2B-q4f16_1` (1.06 GB) shipping,
443
+ `Qwen3.5-0.8B-q4f16_1-MLC` (443 MB) as the baseline most of the analysis was done on.
444
+
445
+ | | 2B | 0.8B |
446
+ | --- | --- | --- |
447
+ | Model load, cache only, zero network | 51 s | 48 s |
448
+ | Prefill | 48 tok/s short, 100–200 at length | 95–98 tok/s |
449
+ | **Decode** | **16.6–18.1 tok/s** | **25.9 tok/s** |
450
+ | Decode, stock single-step WebLLM | — | 9.6 tok/s |
451
+ | Second engine, 4-prompt batch | 1.06x | — |
452
+
453
+ Reproduce with `npm run e2e`. The full analysis — where every millisecond goes, why a second engine
454
+ buys isolation rather than throughput, and what is still open — is in [AI.md](AI.md).
455
+
456
+ ## Stability
457
+
458
+ `chat.completions.create()` is the compatibility layer. **It does not change**, and any change to
459
+ its request or response shape is a major version. That is the whole point of the one-line migration:
460
+ if it drifted, the line would not be one line.
461
+
462
+ Everything else is pre-1.0 and may move. The ergonomic verbs (`load`, `unload`, `environment`) are
463
+ being consolidated — see [ROADMAP.md](ROADMAP.md).
464
+
465
+ ## Docs
466
+
467
+ | | |
468
+ | --- | --- |
469
+ | [API.md](API.md) | Every way to call it — one page, asserted against the code. |
470
+ | [examples/](examples/) | Three runnable projects. Read one before reading the source. |
471
+ | [AI.md](AI.md) | What is true and measured. The reference. |
472
+ | [ROADMAP.md](ROADMAP.md) | The only list of open work. |
473
+ | [ARCHIVE.md](ARCHIVE.md) | What was done and *why* — decisions with their reasoning. |
474
+ | [WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) | What WebLLM already does, where our line is, and the dependency-bump runbook. |
475
+ | [MLC-COMPILE.md](MLC-COMPILE.md) | How the model was compiled, and every toolchain breakage on the way. |
476
+
477
+ ## Development
478
+
479
+ ```sh
480
+ npm run build # bundle WebLLM + apply the two patches
481
+ npm test # contract, patches, README, examples, scheduler, multistep — GPU-free
482
+ npm run e2e # real Firefox + real GPU + real model
483
+ npm run verify-consumer # pack + install for real; proves the Vite plugin is needed and works
484
+ npm run verify-patches # check patch anchors without rebuilding
485
+ ```
486
+
487
+ `@mlc-ai/web-llm` is pinned exactly, because the build rewrites its bundle at verified anchors.
488
+ Bumping it is a documented, guarded procedure — see WEBLLM-SURFACE.md, "Upgrading".
489
+
490
+ ## Licence
491
+
492
+ This package is **ISC** ([LICENSE](LICENSE)). It ships a pre-built copy of WebLLM
493
+ (`vendor/web-llm.js`, **Apache-2.0**) and its dependency `loglevel` (**MIT**); their full licence
494
+ texts travel in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md), and
495
+ [test/license.test.mjs](test/license.test.mjs) fails the build if a dependency is ever bundled
496
+ without one.
ROADMAP.md ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Roadmap — open work only
2
+
3
+ **This is the only list of open work.** Anything finished moves to [ARCHIVE.md](ARCHIVE.md) with the
4
+ reasoning that produced it. [AI.md](AI.md) is the reference for what is true and measured;
5
+ [API.md](API.md) is the full call surface, asserted against the code;
6
+ [WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) is what WebLLM already does and must be read before adding a
7
+ capability.
8
+
9
+ Sections are named, not numbered. An earlier split across three files used "Track 1"/"Track 2" to
10
+ mean different things in each, which is how a reader ends up implementing the wrong item.
11
+
12
+ **Sequencing.** The whole project exists to make WebLLM *more compatible, easier to use, foolproof
13
+ to build on*. Every item below is ranked against that. `everything-webgpu@0.1.0` is on npm as of
14
+ this writing — the install path, the four-line demo, the ergonomic verbs, `API.md` and `examples/`
15
+ all shipped and are recorded in [ARCHIVE.md](ARCHIVE.md). Section 1 is what remains before the
16
+ extraction is fully proven: the extension itself rebuilding on the package. Section 4 (performance)
17
+ is frozen as a block, except for "Measure Ollama", until a real user calls it slow: it is the
18
+ highest-risk, highest-effort work and it re-touches the tvmjs internals the WebLLM-upgrade section
19
+ (also in [ARCHIVE.md](ARCHIVE.md)) just spent its time hardening.
20
+
21
+ ---
22
+
23
+ ## 1. Ship it — the last mile
24
+
25
+ `everything-webgpu@0.1.0` is on npm. What shipped — the install path, the four-line demo, the
26
+ ergonomic verbs, [API.md](API.md), [examples/](examples/), the bundle-size story, licence
27
+ compliance — is in [ARCHIVE.md](ARCHIVE.md), "Shipping 0.1.0". What remains is proving the boundary
28
+ from the other side.
29
+
30
+ - [ ] **`demo` consumes the package**, and the popup + manager leave `main`. The acceptance test for
31
+ the whole extraction: if the extension rebuilds on the published package, the boundary is
32
+ right. `npm run e2e` passing is the same claim at the source-tree level.
33
+ - [~] **Gate A — a bare Vite page loads a prebuilt model and generates.**
34
+ [examples/bare/](examples/bare/) exists and the GPU-free half is proven: `vite build` resolves
35
+ through the `exports` map and splits into an entry chunk, the decode worker as its own chunk,
36
+ and the 6 MB WebLLM bundle as a lazy one. What remains is **one run in a WebGPU browser** to
37
+ confirm it generates. `npm run verify-consumer` already exercises a real install end to end,
38
+ short of the actual generation.
39
+ - [ ] **Gate A′ — the same page ingests a local folder.** `cache.put()` against the synthetic
40
+ `local-model.invalid` key is proven only on an extension origin. Gates the *offline* route,
41
+ not the library.
42
+ - [ ] **Gate B — Chrome.** Measure tok/s. Expected to beat Firefox because KV reuse is not disabled
43
+ there. Entirely a prediction today; `probeDevice()` is the instrument that makes it reportable.
44
+ - [ ] **Every `ERROR` code carries an actionable `fix`.** The preflight (`environment()`) and the
45
+ typed errors (9 codes now, `PACKAGE_INCOMPLETE` included) both exist; what is not yet done is
46
+ the systematic pass confirming each code's `message`/`detail` names a cause *and* a fix, and
47
+ that the README/API.md point at `environment()` as the "will this run here?" call it is meant
48
+ to be.
49
+
50
+ ## 2. Verb consolidation — done
51
+
52
+ `load(src, opts)`, `unload(id, level)` / `remove()` / `unloadAll()`, and `environment()` +
53
+ `environment.measure()` all shipped. The reasoning — the two dropped `load()` dispatch rules, why a
54
+ bare `unload()` frees only the current model, why read and write are split — is in
55
+ [ARCHIVE.md](ARCHIVE.md), "Verb consolidation". `chat.completions.create()` is unchanged and stays
56
+ that way.
57
+
58
+ ## 3. Engine capability
59
+
60
+ `prefetch()`, `embed()` / `embedRaw()`, and the `ask()` / `conversation()` / `ghostText()` recipes
61
+ shipped — see [ARCHIVE.md](ARCHIVE.md), "Engine capability". One item is still open:
62
+
63
+ - [ ] **LRU eviction** when quota is short. `cacheState()` and `evict()` exist; nothing yet decides
64
+ *what* to drop. Table stakes for "it just works" the moment a second model is cached — but the
65
+ eviction policy is a decision, not an implementation detail.
66
+
67
+ ## 4. Performance — frozen, except the measurement
68
+
69
+ Model- and kernel-level work, independent of the library structure — and therefore independent of
70
+ everything this project is *for*. **Frozen as a block until a real user calls it slow.** The library
71
+ ships now, so "nobody can install it" is no longer the argument; the argument is that this is the
72
+ highest-risk, highest-effort work on the page, it re-touches the tvmjs internals the WebLLM-upgrade
73
+ section (in [ARCHIVE.md](ARCHIVE.md)) just spent its time hardening, and a speedup nobody has asked
74
+ for is risk without a return. The one exception is "Measure Ollama": it is cheap, and it decides
75
+ whether any of the rest is worth its risk.
76
+
77
+ - [ ] **Retune the dlight GEMV schedule** — more work per thread before the reduction. Measured
78
+ **1.83x** in isolation, which would put decode near the ~41 GB/s dequant ceiling. Confirmed
79
+ untaken on the 2B build. Needs a recompile; toolchain is stood up.
80
+ - [ ] **Batched decode.** The model lib exports `batch_decode` / `batch_prefill` / `batch_verify`
81
+ and a paged KV cache; WebLLM hardcodes `defaultMaxNumSequence = 1` and `numSamples = 1`
82
+ (bundle lines 15250, 15276). Lifting that reads the weights once per step for N sequences —
83
+ projected **~4x on the `batch` API**, which is the page-translation shape and the one workload
84
+ the engine currently makes no faster than a loop of `chat` calls. No recompilation needed.
85
+ **Not** a fix for the 1.06x second-engine measurement: a second engine buys task isolation,
86
+ never aggregate throughput, and the two are complementary rather than alternatives.
87
+ - [ ] **Interrupt granularity in `sampleBurst`.** `interruptGenerate()` only sets a flag the
88
+ caller's loop reads between `decode()` calls, and a burst runs all K forward steps without
89
+ checking it — so a preempted job finishes its whole burst first (~583 ms at K=15). It binds in
90
+ three cases: the seconds-long window while `#grow()` builds a second engine, a machine where
91
+ `#growthBlocked` is set, and any third concurrent task once the pool is at cap. Fix in our
92
+ `multistep.js`, not WebLLM; `discardLookahead` already handles the resulting state.
93
+ - [ ] **Auto-tune `decodeSteps` from the burst probe.** K=15 is a constant derived from one machine
94
+ and one model — Firefox on Apple Silicon, `Qwen3.5-0.8B` at ~7.3 ms/step. `multistep.js`'s own
95
+ header says the best K falls as the model grows (a 25 ms/step model wants K=4), so the shipped
96
+ default is wrong for every model but the one it was measured on, and wrong silently: a bad K
97
+ costs throughput and reports nothing.
98
+
99
+ **The infrastructure is already there.** `onBurst` delivers `{ steps, tokens, ms, encodeMs,
100
+ syncMs, dispatches, forwardDispatches, flushes }` per burst
101
+ ([multistep.js:246](src/engine/multistep.js#L246)), `setSteps()` is hot
102
+ ([multistep.js:259](src/engine/multistep.js#L259)), and `WORKER_CONFIGURE` already calls
103
+ `resetStats()` on every retune so "a sweep's points never bleed into each other"
104
+ ([engine-worker.js:137](src/engine/engine-worker.js#L137)). It was built for this.
105
+
106
+ **The obvious derivation does not work.** `K = floor(tickMs / perStepMs)` needs `perStepMs`, and
107
+ the probe cannot separate it: `syncMs` is GPU execution *plus* the wait for the poll tick, which
108
+ is the quantity being solved for. Only `encodeMs` is clean — the K-step loop contains no `await`,
109
+ so it is content-process CPU alone — and it is the smaller half.
110
+
111
+ **What works instead** is a hill-climb on the one quantity directly observed, `tokens / ms`.
112
+ Throughput at K is `K / (ceil(K·p/tick)·tick)`; its peaks sit at `K = floor(n·tick/p)` and are
113
+ asymptotically equal in n, so the first peak is the target and climbing K until throughput drops
114
+ finds it without ever naming `p` or `tick`. That is also what makes it survive Chrome, where the
115
+ 100 ms tick premise does not hold.
116
+
117
+ **Three subtleties that will bite.** (1) `onBurst.steps` is the *clamped* burst size —
118
+ `burstSize()` cuts it by `max_tokens` and the context window
119
+ ([multistep.js:297](src/engine/multistep.js#L297)) — so the tuner must learn only from bursts
120
+ where `steps === config.steps`, or it learns from end-of-generation stubs. (2) The first burst on
121
+ a pipeline carries shader compilation; skip it. (3) `clampSteps("auto")` is **1** today
122
+ (`Number("auto")` → NaN → `|| 1`), so an `"auto"` sentinel silently *disables* multi-step unless
123
+ `clampSteps` and [API.md](API.md)'s documented `1`–`32` range change together —
124
+ `api-doc.test.mjs` asserts the two agree.
125
+
126
+ **Sequencing.** Composes with per-priority K below rather than replacing it: this finds the
127
+ throughput ceiling K_max for the device+model, per-priority then spends it (`interactive` takes
128
+ min(K_max, 4), `background` takes K_max). Do this one first — per-priority is written against the
129
+ 15 this replaces. Unlike the rest of section 4 it touches **no new tvmjs internals**: it reads
130
+ telemetry that already flows and calls a setter that already exists, which is the argument for
131
+ lifting the freeze on this item alone. It cannot be *validated* here, though — the sawtooth only
132
+ exists on real hardware, and Gate B (Chrome) is what proves the controller is finding a real peak
133
+ rather than re-deriving 15.
134
+
135
+ - [ ] **Per-priority `decodeSteps`.** K=15 maximises throughput but emits 15 tokens every ~583 ms,
136
+ which reads as a stall. `interactive` should use K=2–4, `background`/`batch` K=32. Do this
137
+ *after* the interrupt fix — raising K for background work lengthens exactly the bursts that
138
+ preemption has to wait out.
139
+ - [ ] **Restore cross-turn KV reuse.** Every turn re-prefills the whole history at **5.27 ms/token**,
140
+ so a turn near the 4096 limit pays ~22 s before its first token. Pack
141
+ `batch_prefill_paged_kv_kernel`'s six i32 metadata buffers into one with offsets (10 bindings
142
+ → 5); the offsets already exist in its uniform block.
143
+
144
+ ## 5. Test and infrastructure
145
+
146
+ Both are "fix when it next bites", not scheduled work. The completed e2e run that used to sit here
147
+ is in [ARCHIVE.md](ARCHIVE.md), including the `storageBuffersPerStage=9` anomaly worth re-checking
148
+ on the next run.
149
+
150
+ - [ ] Fix `PROFILE_PATH` in [test/e2e/run.mjs](test/e2e/run.mjs): it passes `--profile-path`, but
151
+ web-ext 8 calls it `--firefox-profile` and exits with `Unknown arguments`.
152
+ - [~] **Isolate the bench's pass-sweep onto its own device** — done, and it **did not do what the
153
+ item assumed**. 2048 compute passes in one encoder loses the WebGPU device, and a lost device
154
+ does not throw: later calls silently no-op.
155
+
156
+ Two corrections from measuring it. First, the stated worry (later probes poisoned) was empty
157
+ — the sweep was already the last phase, so nothing ran after it. The real damage was to the
158
+ sweep's **own** numbers: the run that prompted this reported `perPass=-3.9us`, 512 passes
159
+ measured faster than one, which is the device dying mid-sweep and the remaining submits
160
+ becoming free. That half is fixed — `n512` now reads a plausible ~10us and `n2048` reports
161
+ `discarded (device lost during the 2048-pass encode)` instead of inventing a number.
162
+
163
+ Second, **isolation does not contain the loss.** With the sweep on its own device,
164
+ `deviceLostDuringBench` still reports the main device lost during this phase, on both runs. A
165
+ runaway command buffer on Metal appears to reset the whole adapter rather than one device on
166
+ it. So the honest state is: sweep numbers are now trustworthy, containment is not achieved,
167
+ and the bench is safe only because this is the last phase.
168
+
169
+ Still open, if containment is wanted: run the sweep in a dedicated worker or a separate page
170
+ so the reset cannot reach the measuring context at all. Also worth noting the attribution is
171
+ imprecise — `lostDuring` records the phase at which `device.lost` *resolves*, and the
172
+ 16384-dispatch probe immediately above is another plausible culprit.
173
+
174
+ ## 6. Deferred, with the condition that would reopen them
175
+
176
+ | Item | Reopens when |
177
+ | --- | --- |
178
+ | **Vision / image understanding** | An in-house compact VLM exists. The prebuilt option is `Phi-3.5-vision` at 3.95 GB, projecting ~4–5 tok/s and not co-resident with a text model in 16 GB. `modelType: "vlm"` already flows through registration, so the compile lands on an engine that accepts it. Open questions to answer against the real model: whether multi-step decoding survives an image prefill, and whether the Firefox 9-storage-buffer workaround holds for the vision tower's kernels. |
179
+ | **MV3 migration** | `demo` is forced off MV2. `CreateExtensionServiceWorkerMLCEngine` solves the messaging and in-work keep-alive but **not** the actual problem — an idle service worker is killed and takes the resident multi-GB model with it. Use the native helper for messaging; hold the engine in an offscreen document, persistent page, or dedicated tab. Also verify WebGPU is exposed in a Chrome extension SW at all. |
180
+ | **SRI / `verifyIntegrity`** | Self-hosted models are hardened, or corruption is actually observed. `ModelStore.verify()` checks key *presence*; `verifyIntegrity` checks *content* — different problems. Use the native one; do not hand-roll a hash check. |
181
+ | **Multi-model via `reload([...])`** | Memory pressure shows up before scheduling contention does. Rejected for now: `reload()` is all-or-nothing, so adding a third model to `{A, B}` reloads A and B too (~51 s each). Additive residency is a hard requirement and only `#pools` provides it. |
THIRD-PARTY-NOTICES.md ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-party notices
2
+
3
+ `everything-webgpu` ships a pre-built, lightly-patched copy of WebLLM at
4
+ `vendor/web-llm.js` (see [WEBLLM-SURFACE.md](WEBLLM-SURFACE.md) and
5
+ [build/patches.mjs](build/patches.mjs) for what the patches are and why). That
6
+ file is a bundle: it contains the components listed below, and their licenses
7
+ travel with it.
8
+
9
+ The patches change two code paths for Firefox's WebGPU limits and compute-pass
10
+ batching; they add no third-party code. Everything bundled is enumerated here.
11
+
12
+ ---
13
+
14
+ ## @mlc-ai/web-llm 0.2.84
15
+
16
+ - License: **Apache-2.0**
17
+ - Project: https://github.com/mlc-ai/web-llm
18
+
19
+ WebLLM's own LICENSE file (reproduced verbatim below) also carries a summary of
20
+ the components WebLLM itself vendors.
21
+
22
+ ```
23
+ Apache License
24
+ Version 2.0, January 2004
25
+ http://www.apache.org/licenses/
26
+
27
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
28
+
29
+ 1. Definitions.
30
+
31
+ "License" shall mean the terms and conditions for use, reproduction,
32
+ and distribution as defined by Sections 1 through 9 of this document.
33
+
34
+ "Licensor" shall mean the copyright owner or entity authorized by
35
+ the copyright owner that is granting the License.
36
+
37
+ "Legal Entity" shall mean the union of the acting entity and all
38
+ other entities that control, are controlled by, or are under common
39
+ control with that entity. For the purposes of this definition,
40
+ "control" means (i) the power, direct or indirect, to cause the
41
+ direction or management of such entity, whether by contract or
42
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
43
+ outstanding shares, or (iii) beneficial ownership of such entity.
44
+
45
+ "You" (or "Your") shall mean an individual or Legal Entity
46
+ exercising permissions granted by this License.
47
+
48
+ "Source" form shall mean the preferred form for making modifications,
49
+ including but not limited to software source code, documentation
50
+ source, and configuration files.
51
+
52
+ "Object" form shall mean any form resulting from mechanical
53
+ transformation or translation of a Source form, including but
54
+ not limited to compiled object code, generated documentation,
55
+ and conversions to other media types.
56
+
57
+ "Work" shall mean the work of authorship, whether in Source or
58
+ Object form, made available under the License, as indicated by a
59
+ copyright notice that is included in or attached to the work
60
+ (an example is provided in the Appendix below).
61
+
62
+ "Derivative Works" shall mean any work, whether in Source or Object
63
+ form, that is based on (or derived from) the Work and for which the
64
+ editorial revisions, annotations, elaborations, or other modifications
65
+ represent, as a whole, an original work of authorship. For the purposes
66
+ of this License, Derivative Works shall not include works that remain
67
+ separable from, or merely link (or bind by name) to the interfaces of,
68
+ the Work and Derivative Works thereof.
69
+
70
+ "Contribution" shall mean any work of authorship, including
71
+ the original version of the Work and any modifications or additions
72
+ to that Work or Derivative Works thereof, that is intentionally
73
+ submitted to Licensor for inclusion in the Work by the copyright owner
74
+ or by an individual or Legal Entity authorized to submit on behalf of
75
+ the copyright owner. For the purposes of this definition, "submitted"
76
+ means any form of electronic, verbal, or written communication sent
77
+ to the Licensor or its representatives, including but not limited to
78
+ communication on electronic mailing lists, source code control systems,
79
+ and issue tracking systems that are managed by, or on behalf of, the
80
+ Licensor for the purpose of discussing and improving the Work, but
81
+ excluding communication that is conspicuously marked or otherwise
82
+ designated in writing by the copyright owner as "Not a Contribution."
83
+
84
+ "Contributor" shall mean Licensor and any individual or Legal Entity
85
+ on behalf of whom a Contribution has been received by Licensor and
86
+ subsequently incorporated within the Work.
87
+
88
+ 2. Grant of Copyright License. Subject to the terms and conditions of
89
+ this License, each Contributor hereby grants to You a perpetual,
90
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
91
+ copyright license to reproduce, prepare Derivative Works of,
92
+ publicly display, publicly perform, sublicense, and distribute the
93
+ Work and such Derivative Works in Source or Object form.
94
+
95
+ 3. Grant of Patent License. Subject to the terms and conditions of
96
+ this License, each Contributor hereby grants to You a perpetual,
97
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
98
+ (except as stated in this section) patent license to make, have made,
99
+ use, offer to sell, sell, import, and otherwise transfer the Work,
100
+ where such license applies only to those patent claims licensable
101
+ by such Contributor that are necessarily infringed by their
102
+ Contribution(s) alone or by combination of their Contribution(s)
103
+ with the Work to which such Contribution(s) was submitted. If You
104
+ institute patent litigation against any entity (including a
105
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
106
+ or a Contribution incorporated within the Work constitutes direct
107
+ or contributory patent infringement, then any patent licenses
108
+ granted to You under this License for that Work shall terminate
109
+ as of the date such litigation is filed.
110
+
111
+ 4. Redistribution. You may reproduce and distribute copies of the
112
+ Work or Derivative Works thereof in any medium, with or without
113
+ modifications, and in Source or Object form, provided that You
114
+ meet the following conditions:
115
+
116
+ (a) You must give any other recipients of the Work or
117
+ Derivative Works a copy of this License; and
118
+
119
+ (b) You must cause any modified files to carry prominent notices
120
+ stating that You changed the files; and
121
+
122
+ (c) You must retain, in the Source form of any Derivative Works
123
+ that You distribute, all copyright, patent, trademark, and
124
+ attribution notices from the Source form of the Work,
125
+ excluding those notices that do not pertain to any part of
126
+ the Derivative Works; and
127
+
128
+ (d) If the Work includes a "NOTICE" text file as part of its
129
+ distribution, then any Derivative Works that You distribute must
130
+ include a readable copy of the attribution notices contained
131
+ within such NOTICE file, excluding those notices that do not
132
+ pertain to any part of the Derivative Works, in at least one
133
+ of the following places: within a NOTICE text file distributed
134
+ as part of the Derivative Works; within the Source form or
135
+ documentation, if provided along with the Derivative Works; or,
136
+ within a display generated by the Derivative Works, if and
137
+ wherever such third-party notices normally appear. The contents
138
+ of the NOTICE file are for informational purposes only and
139
+ do not modify the License. You may add Your own attribution
140
+ notices within Derivative Works that You distribute, alongside
141
+ or as an addendum to the NOTICE text from the Work, provided
142
+ that such additional attribution notices cannot be construed
143
+ as modifying the License.
144
+
145
+ You may add Your own copyright statement to Your modifications and
146
+ may provide additional or different license terms and conditions
147
+ for use, reproduction, or distribution of Your modifications, or
148
+ for any such Derivative Works as a whole, provided Your use,
149
+ reproduction, and distribution of the Work otherwise complies with
150
+ the conditions stated in this License.
151
+
152
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
153
+ any Contribution intentionally submitted for inclusion in the Work
154
+ by You to the Licensor shall be under the terms and conditions of
155
+ this License, without any additional terms or conditions.
156
+ Notwithstanding the above, nothing herein shall supersede or modify
157
+ the terms of any separate license agreement you may have executed
158
+ with Licensor regarding such Contributions.
159
+
160
+ 6. Trademarks. This License does not grant permission to use the trade
161
+ names, trademarks, service marks, or product names of the Licensor,
162
+ except as required for reasonable and customary use in describing the
163
+ origin of the Work and reproducing the content of the NOTICE file.
164
+
165
+ 7. Disclaimer of Warranty. Unless required by applicable law or
166
+ agreed to in writing, Licensor provides the Work (and each
167
+ Contributor provides its Contributions) on an "AS IS" BASIS,
168
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
169
+ implied, including, without limitation, any warranties or conditions
170
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
171
+ PARTICULAR PURPOSE. You are solely responsible for determining the
172
+ appropriateness of using or redistributing the Work and assume any
173
+ risks associated with Your exercise of permissions under this License.
174
+
175
+ 8. Limitation of Liability. In no event and under no legal theory,
176
+ whether in tort (including negligence), contract, or otherwise,
177
+ unless required by applicable law (such as deliberate and grossly
178
+ negligent acts) or agreed to in writing, shall any Contributor be
179
+ liable to You for damages, including any direct, indirect, special,
180
+ incidental, or consequential damages of any character arising as a
181
+ result of this License or out of the use or inability to use the
182
+ Work (including but not limited to damages for loss of goodwill,
183
+ work stoppage, computer failure or malfunction, or any and all
184
+ other commercial damages or losses), even if such Contributor
185
+ has been advised of the possibility of such damages.
186
+
187
+ 9. Accepting Warranty or Additional Liability. While redistributing
188
+ the Work or Derivative Works thereof, You may choose to offer,
189
+ and charge a fee for, acceptance of support, warranty, indemnity,
190
+ or other liability obligations and/or rights consistent with this
191
+ License. However, in accepting such obligations, You may act only
192
+ on Your own behalf and on Your sole responsibility, not on behalf
193
+ of any other Contributor, and only if You agree to indemnify,
194
+ defend, and hold each Contributor harmless for any liability
195
+ incurred by, or claims asserted against, such Contributor by reason
196
+ of your accepting any such warranty or additional liability.
197
+
198
+ END OF TERMS AND CONDITIONS
199
+
200
+ APPENDIX: How to apply the Apache License to your work.
201
+
202
+ To apply the Apache License to your work, attach the following
203
+ boilerplate notice, with the fields enclosed by brackets "[]"
204
+ replaced with your own identifying information. (Don't include
205
+ the brackets!) The text should be enclosed in the appropriate
206
+ comment syntax for the file format. We also recommend that a
207
+ file or class name and description of purpose be included on the
208
+ same "printed page" as the copyright notice for easier
209
+ identification within third-party archives.
210
+
211
+ Copyright [yyyy] [name of copyright owner]
212
+
213
+ Licensed under the Apache License, Version 2.0 (the "License");
214
+ you may not use this file except in compliance with the License.
215
+ You may obtain a copy of the License at
216
+
217
+ http://www.apache.org/licenses/LICENSE-2.0
218
+
219
+ Unless required by applicable law or agreed to in writing, software
220
+ distributed under the License is distributed on an "AS IS" BASIS,
221
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
222
+ See the License for the specific language governing permissions and
223
+ limitations under the License.
224
+
225
+ ------------------------------------------------------------------------------------
226
+ This product bundles various third-party components under other open source licenses.
227
+ This section summarizes those components and their licenses. See licenses/
228
+ for text of these licenses.
229
+
230
+ Apache Software Foundation License 2.0
231
+ --------------------------------------
232
+
233
+ src/openai_api_protocols
234
+ ```
235
+
236
+ ---
237
+
238
+ ## loglevel 1.9.2
239
+
240
+ - License: **MIT**
241
+ - Project: https://github.com/pimterry/loglevel
242
+ - Bundled transitively as WebLLM's only runtime dependency.
243
+
244
+ ```
245
+ Copyright (c) 2013 Tim Perry
246
+
247
+ Permission is hereby granted, free of charge, to any person
248
+ obtaining a copy of this software and associated documentation
249
+ files (the "Software"), to deal in the Software without
250
+ restriction, including without limitation the rights to use,
251
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
252
+ copies of the Software, and to permit persons to whom the
253
+ Software is furnished to do so, subject to the following
254
+ conditions:
255
+
256
+ The above copyright notice and this permission notice shall be
257
+ included in all copies or substantial portions of the Software.
258
+
259
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
260
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
261
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
262
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
263
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
264
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
265
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
266
+ OTHER DEALINGS IN THE SOFTWARE.
267
+ ```
WEBLLM-SURFACE.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WebLLM surface, and where the line is
2
+
3
+ `@mlc-ai/web-llm` **0.2.84**, pinned exactly (`build.mjs` rewrites the bundle at anchors it verifies
4
+ first, and throws on a miss, so a minor bump breaks the build for every consumer).
5
+
6
+ This file exists because it was needed and absent. Building "a superset of WebLLM" without a written
7
+ inventory of WebLLM meant reaching for `grep` one narrow question at a time, and three functions got
8
+ reimplemented that were exported all along — see "Removed", below. **Check this list before adding a
9
+ capability**, per AI.md's Reuse First. Re-check it on every dependency bump.
10
+
11
+ **Every dependency listed here is asserted by
12
+ [test/webllm-contract.test.mjs](test/webllm-contract.test.mjs)** (`npm run contract`). Prose does not
13
+ fail a build; that file does. Add an assertion there whenever you add a row here.
14
+
15
+ ## Upgrading `@mlc-ai/web-llm`
16
+
17
+ The pin is exact, and three independent guards catch three different kinds of drift. A bump is a
18
+ checklist that fails loudly at the right line, not an afternoon of `grep`.
19
+
20
+ ```sh
21
+ npm i @mlc-ai/web-llm@<new>
22
+ npm run build # 1. surface drift — patches verify before they rewrite
23
+ npm test # 2. semantic drift — the contract asserts every shape we depend on
24
+ npm run e2e # 3. behavioural drift — real GPU, byte-identical greedy generation
25
+ ```
26
+
27
+ **1. Surface drift — `npm run build`.** [build/patches.mjs](build/patches.mjs) verifies all four
28
+ patch anchors against a fresh bundle *before* rewriting anything, so a bump reports every break at
29
+ once rather than the first over a half-patched file. A miss prints the anchor, the WebLLM version it
30
+ last held against, and the nearest lines ranked by identifier rarity; for a vanished identifier it
31
+ also names the closest surviving one by trigram overlap — an upstream rename of
32
+ `requiredMaxStorageBuffersPerShaderStage` shows up as an 85%-similar name with its line number.
33
+ Anchors match modulo whitespace and, where the literal string is generic (`compute.end();`), are
34
+ scoped to an enclosing function, so reflow and unrelated new code do not cause false failures.
35
+ `npm run verify-patches` runs just this check without rebuilding. Re-anchoring still needs a human to
36
+ *approve* the new location: edit `build/patches.mjs`, re-run, repeat until green.
37
+
38
+ **2. Semantic drift — `npm test`.** [test/webllm-contract.test.mjs](test/webllm-contract.test.mjs)
39
+ (`npm run contract` alone, GPU-free) asserts every export, field, enum value and finish-reason
40
+ literal in the tables below, plus:
41
+
42
+ - the ~30 undocumented tvmjs pipeline internals `multistep.js` reaches into — the list is *derived
43
+ from that file's own source* and pinned to `PIPELINE_CONTRACT`, so neither can fall behind the code
44
+ - `decode_tokens_per_s` still present in the bundle — `estimateSpeed()`'s measured path needs it
45
+ - `model_lib` still unguessable from base + id — the "require it for a remote source" rule in
46
+ ROADMAP.md's verb-consolidation section depends on this staying true
47
+
48
+ A failure here is the dangerous kind: every symbol still resolves and behaviour changed anyway. Read
49
+ it before touching anything else. `test/patches.test.mjs` and `test/integration.test.mjs` also run
50
+ here and guard the patch machinery and cache-scope names.
51
+
52
+ **3. Behavioural drift — `npm run e2e`.** Real Firefox, real GPU, real model, drag-and-drop
53
+ ingestion through the production `src/engine/` + `src/adapters/` paths. Asserts greedy generation is
54
+ byte-identical and the KV-reuse pipeline matches a forced re-prefill. The only guard that catches a
55
+ change where every name survives, every shape matches, and the tokens are still wrong.
56
+
57
+ **Then, by hand:**
58
+
59
+ - Bump the version line at the top of this file.
60
+ - Re-run the export list and diff it against the previous run:
61
+ ```sh
62
+ node -e 'import("./vendor/web-llm.js").then(m=>console.log(Object.keys(m).sort().join("\n")))'
63
+ ```
64
+ A new export is a candidate for "available, not used yet". A *removed* one that appears in the
65
+ tables below should already have failed the contract test — if it did not, add the assertion.
66
+ - Walk the tables here for anything the diff or the test output touched.
67
+
68
+ A large tvmjs refactor still needs a human to re-anchor and re-read. The goal is not an automatic
69
+ bump — it is that you find out what broke in seconds and know exactly where to look.
70
+
71
+ ## Native — we call it
72
+
73
+ | Export | Used by | For |
74
+ | --- | --- | --- |
75
+ | `CreateWebWorkerMLCEngine` | `engine.js` | one engine per pool slot, each in its own realm |
76
+ | `WebWorkerMLCEngineHandler` | `engine-worker.js` | the worker side of that |
77
+ | `prebuiltAppConfig` | `engine.js` | 163 HuggingFace models, merged under our registry |
78
+ | `functionCallingModelIds` | `engine.js` | which prebuilt models do tool calling — `listAvailableModels()` tags them, `recommendModels({ needsToolCalling })` filters on them. Shipped in the list; not derivable from an id |
79
+ | `hasModelInCache` | `engine.cacheState()` | is a **remote/prebuilt** model on disk |
80
+ | `deleteModelAllInfoInCache` | `engine.evict()` | delete a **remote/prebuilt** model's bytes |
81
+ | `engine.chat.completions.create` | `pool.js` | the actual generation call |
82
+ | `engine.embeddings.create` | `pool.js` | `embed()` / `embedRaw()`. The OpenAI-shaped namespace (`Embeddings.prototype.create`), not the bare `MLCEngine.embedding()` — one forward pass, routed through the same scheduler as a completion |
83
+ | `usage.extra.decode_tokens_per_s` | `engine.#calibrate()` | measured throughput; calibrates projections |
84
+ | `finish_reason` (`stop`/`length`/`abort`) | `pool.js`, `chat.js` | truncation vs. natural stop vs. interrupt |
85
+ | `interruptGenerate()` | `pool.js` | cancellation and preemption |
86
+ | `unload()` → aborts `reloadController` | `engine.js` | **download cancellation is native**; no machinery of ours |
87
+ | `resetChat()` | `engine-worker.js` | forces re-prefill where paged KV cannot build |
88
+ | `ModelType` | `model-store.js` | `MODEL_TYPE`; a VLM must declare itself or images are refused |
89
+
90
+ ## Native — available, not used yet
91
+
92
+ | Export | Why it matters |
93
+ | --- | --- |
94
+ | `CreateExtensionServiceWorkerMLCEngine` | **A native answer to the MV3 limit in AI.md.** Evaluate before hand-rolling a keep-alive. |
95
+ | `verifyIntegrity` / `isValidSRI` / `IntegrityError` | SRI checks on fetched artifacts — relevant once models are self-hosted. |
96
+ | `reload([a, b])` + routing on `request.model` | Multiple models in **one** engine. See "Overlapping by design". |
97
+ | `runtimeStatsText()` | Formatted prefill/decode stats. `usage.extra` is the structured form and is what we use. |
98
+ | `getGPUVendor()`, `getMaxStorageBufferBindingSize()` | Overlap `probeDevice()` but need a **loaded** engine, so they cannot answer "will this load". |
99
+ | `completion()`, `Completions` | Legacy non-chat completions. No caller has asked. |
100
+
101
+ ## Ours — nothing upstream does this
102
+
103
+ | | Why it cannot be delegated |
104
+ | --- | --- |
105
+ | `pool.js` — priority bands, session supersession, opt-in preemption, one engine per task, demand-driven growth | WebLLM has no scheduler at all |
106
+ | `multistep.js` — K forward steps per GPU sync | 9.7 → 18.4 tok/s; not a WebLLM concept |
107
+ | `build.mjs` — compute-pass batching, storage-buffer clamp | 10.3 → 25.9 tok/s; patches *into* tvmjs |
108
+ | `ingest.js` — validate a folder, write Cache Storage directly | WebLLM only ever fetches; it cannot be handed bytes |
109
+ | `ModelStore.verify()` / `evictInjected()` | We wrote those artifacts and hold the only manifest. WebLLM's equivalents read `tensor-cache.json` to enumerate shards, so they break once *that* file is evicted — the exact case injection has to survive |
110
+ | `device.js` — `probeDevice`, `canRun`, `rankModels` | Preflight, before any download. WebLLM's device getters need a loaded engine |
111
+ | The engine-worker decode probe (`encodeMs`/`syncMs`) | Splits a burst into CPU-encode vs GPU-sync. Nothing upstream reports it, and the multi-step and pass-batching work is measured against it |
112
+ | `absolutize()` in `model-store.js` | WebLLM's `cleanModelUrl` ends in `new URL(url)` with no base, so a relative `/models/x/` throws deep in the loader. We resolve at registration |
113
+ | `errors.js`, the adapters, the wire protocol | Host and API concerns WebLLM has no opinion on |
114
+
115
+ ## Rejected — looked delegable, was not
116
+
117
+ **Multi-model residency.** `reload(["a","b"])` holds N models in one engine and routes on
118
+ `request.model` — cheaper in memory, one realm instead of one worker per model. First presented here
119
+ as "not necessary in the way it was first presented", which was wrong: `reload()` calls `unload()`
120
+ unconditionally before loading, so it is **all-or-nothing** — adding a third model to `{A, B}` reloads
121
+ A and B too, ~51 s each on the 2B. There is no incremental residency upstream. Our `#pools:
122
+ Map<modelId, EnginePool>` is the only thing that provides additive residency, which is a hard
123
+ requirement (switching models must not cost re-downloading the ones already up). See ROADMAP.md,
124
+ "Deferred", for the reopening condition.
125
+
126
+ ## Removed, having been found redundant
127
+
128
+ | Was | Now |
129
+ | --- | --- |
130
+ | `cleanModelUrl()` reimplemented in `model-store.js` | deleted — only existed to support the two below |
131
+ | `ModelStore.cacheKeysFor()` | deleted — WebLLM derives the keys it fetched |
132
+ | `ModelStore.cacheState()` | `engine.cacheState()`, delegating to `hasModelInCache` for remote |
133
+ | `ModelStore.evict()` (all sources) | `ModelStore.evictInjected()` + `engine.evict()` routing to `deleteModelAllInfoInCache` |
134
+ | speed re-derived from the worker probe | `usage.extra.decode_tokens_per_s`, which every response already carried |
package.json ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "everything-webgpu",
3
+ "version": "0.1.0",
4
+ "description": "Firefox WebExtension and embeddable library that runs local MLC/WebLLM models on WebGPU, with a priority scheduler, multi-step decoding, and models from HuggingFace, your own URL, or injected straight from disk.",
5
+ "scripts": {
6
+ "build": "node build.mjs",
7
+ "check": "node build.mjs --check-entries",
8
+ "test": "node --test test/webllm-contract.test.mjs test/patches.test.mjs test/readme.test.mjs test/api-doc.test.mjs test/examples.test.mjs test/license.test.mjs test/integration.test.mjs test/scheduler.test.mjs test/multistep.test.mjs test/environment.test.mjs test/sources.test.mjs test/prefetch.test.mjs test/embed.test.mjs test/recipes.test.mjs test/errors.test.mjs test/chat.test.mjs test/device.test.mjs test/manage.test.mjs",
9
+ "package": "npm run build && node build.mjs --zip",
10
+ "e2e": "node test/e2e/run.mjs",
11
+ "verify-consumer": "node test/consumer/verify.mjs",
12
+ "bench": "node test/e2e/bench.mjs",
13
+ "types": "tsc -p tsconfig.json",
14
+ "prepare": "node build.mjs",
15
+ "prepublishOnly": "npm run types && npm test",
16
+ "contract": "node --test test/webllm-contract.test.mjs",
17
+ "verify-patches": "node build.mjs --verify-patches"
18
+ },
19
+ "author": "nyaaorick",
20
+ "license": "ISC",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/nyaaorick/everything-webgpu.git"
24
+ },
25
+ "homepage": "https://github.com/nyaaorick/everything-webgpu#readme",
26
+ "bugs": "https://github.com/nyaaorick/everything-webgpu/issues",
27
+ "dependencies": {
28
+ "@mlc-ai/web-llm": "0.2.84"
29
+ },
30
+ "devDependencies": {
31
+ "acorn": "^8.18.0",
32
+ "esbuild": "^0.28.2",
33
+ "typescript": "^5.9.2"
34
+ },
35
+ "type": "module",
36
+ "types": "types/engine/index.d.ts",
37
+ "exports": {
38
+ ".": {
39
+ "types": "./types/engine/index.d.ts",
40
+ "default": "./src/engine/index.js"
41
+ },
42
+ "./worker": "./src/engine/engine-worker.js",
43
+ "./vite": {
44
+ "types": "./types/vite.d.ts",
45
+ "default": "./src/vite.js"
46
+ },
47
+ "./adapters/idb": {
48
+ "types": "./types/adapters/idb.d.ts",
49
+ "default": "./src/adapters/idb.js"
50
+ },
51
+ "./adapters/memory": {
52
+ "types": "./types/adapters/memory.d.ts",
53
+ "default": "./src/adapters/memory.js"
54
+ },
55
+ "./adapters/webext": {
56
+ "types": "./types/adapters/webext.d.ts",
57
+ "default": "./src/adapters/webext.js"
58
+ },
59
+ "./adapters/protocol": {
60
+ "types": "./types/adapters/protocol.d.ts",
61
+ "default": "./src/adapters/protocol.js"
62
+ }
63
+ },
64
+ "files": [
65
+ "src/engine",
66
+ "src/adapters",
67
+ "src/vite.js",
68
+ "types",
69
+ "vendor/web-llm.js",
70
+ "THIRD-PARTY-NOTICES.md"
71
+ ]
72
+ }
src/adapters/idb.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * IndexedDB StorageAdapter — the default for an ordinary page.
3
+ *
4
+ * IndexedDB rather than `localStorage` for two reasons that both matter here:
5
+ * `localStorage` is synchronous and blocks the thread that is also driving
6
+ * WebGPU, and it does not exist in a worker. The registry itself is small (one
7
+ * record per model, a few dozen cache URLs each), so this is a key/value store
8
+ * and nothing more.
9
+ *
10
+ * `persist()` is exported alongside because the page origin, unlike an
11
+ * extension with `unlimitedStorage`, holds a multi-GB model in *evictable*
12
+ * storage until persistence is granted. See ARCHIVE.md, "Zero-download".
13
+ */
14
+ const DB_NAME = "everything-webgpu";
15
+ const DB_VERSION = 1;
16
+ const STORE = "kv";
17
+
18
+ function open() {
19
+ return new Promise((resolve, reject) => {
20
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
21
+ req.onupgradeneeded = () => req.result.createObjectStore(STORE);
22
+ req.onsuccess = () => resolve(req.result);
23
+ req.onerror = () => reject(req.error);
24
+ });
25
+ }
26
+
27
+ function run(db, mode, fn) {
28
+ return new Promise((resolve, reject) => {
29
+ const tx = db.transaction(STORE, mode);
30
+ const out = fn(tx.objectStore(STORE));
31
+ tx.oncomplete = () => resolve(out);
32
+ tx.onerror = () => reject(tx.error);
33
+ tx.onabort = () => reject(tx.error);
34
+ });
35
+ }
36
+
37
+ const get1 = (store, key) =>
38
+ new Promise((resolve, reject) => {
39
+ const req = store.get(key);
40
+ req.onsuccess = () => resolve(req.result);
41
+ req.onerror = () => reject(req.error);
42
+ });
43
+
44
+ /** @returns {Promise<import("../engine/model-store.js").StorageAdapter>} */
45
+ export async function indexedDBStorage() {
46
+ const db = await open();
47
+ return {
48
+ async get(key) {
49
+ const keys = Array.isArray(key) ? key : [key];
50
+ const out = {};
51
+ await run(db, "readonly", async (store) => {
52
+ for (const k of keys) {
53
+ const value = await get1(store, k);
54
+ if (value !== undefined) out[k] = value;
55
+ }
56
+ });
57
+ return out;
58
+ },
59
+ async set(items) {
60
+ await run(db, "readwrite", (store) => {
61
+ for (const [k, v] of Object.entries(items)) store.put(v, k);
62
+ });
63
+ },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Ask the browser to stop treating this origin's storage as evictable.
69
+ *
70
+ * Firefox prompts the user (or grants silently for a site with the permission
71
+ * already); Chrome decides from engagement heuristics without a prompt. Either
72
+ * way a caller must handle `persisted: false` — the model still works, it can
73
+ * just be dropped under disk pressure, which `ModelStore.verify()` will catch
74
+ * on the next load.
75
+ *
76
+ * @returns {Promise<{persisted: boolean, quota?: number, usage?: number}>}
77
+ */
78
+ export async function ensurePersistent() {
79
+ const storage = globalThis.navigator?.storage;
80
+ if (!storage?.persist) return { persisted: false };
81
+ const persisted = (await storage.persisted?.()) || (await storage.persist());
82
+ const estimate = await storage.estimate?.().catch(() => ({}));
83
+ return { persisted: Boolean(persisted), quota: estimate?.quota, usage: estimate?.usage };
84
+ }
src/adapters/memory.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * In-memory StorageAdapter.
3
+ *
4
+ * For tests, and for a host that genuinely wants the registry to die with the
5
+ * page. Note that the *weights* still live in Cache Storage and survive — only
6
+ * the registry entry pointing at them is lost, which `ModelStore.verify()` will
7
+ * then never be asked about. Do not use this in production for that reason.
8
+ */
9
+
10
+ /** @returns {import("../engine/model-store.js").StorageAdapter} */
11
+ export function memoryStorage(initial = {}) {
12
+ const store = new Map(Object.entries(initial));
13
+ return {
14
+ async get(key) {
15
+ const keys = Array.isArray(key) ? key : [key];
16
+ return Object.fromEntries(keys.filter((k) => store.has(k)).map((k) => [k, store.get(k)]));
17
+ },
18
+ async set(items) {
19
+ for (const [k, v] of Object.entries(items)) store.set(k, v);
20
+ },
21
+ };
22
+ }
src/adapters/protocol.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Wire protocol — one adapter's vocabulary, not the engine's interface.
3
+ *
4
+ * A host that holds a `ScheduledEngine` calls its methods directly and never
5
+ * loads this file. This exists for the case where the engine and the caller are
6
+ * in different processes: a WebExtension hosting the engine in its background
7
+ * page, reached from a popup, an options page, or another extension.
8
+ *
9
+ * Two transports, one vocabulary:
10
+ * - `browser.runtime.sendMessage(EXTENSION_ID, req)` for request/response ops.
11
+ * - `browser.runtime.connect(EXTENSION_ID, { name: PORT_NAME })` for streaming.
12
+ *
13
+ * Every message carries `protocol` so a stray message from another sender fails
14
+ * fast instead of being half-interpreted.
15
+ *
16
+ * The engine is one GPU with a hard budget (see AI.md, "The 10 tok/s ceiling"),
17
+ * shared by every caller. So requests carry scheduling metadata, and the engine
18
+ * — not the caller — decides what runs when.
19
+ */
20
+
21
+ // Re-exported so a wire caller needs one import, not two. The definitions live
22
+ // with the scheduler, which is what actually acts on them.
23
+ export { ENGINE_STATE, PRIORITY, PRIORITY_ORDER } from "../engine/constants.js";
24
+
25
+ export const PROTOCOL = "everything-webgpu/v1";
26
+ export const PORT_NAME = PROTOCOL;
27
+
28
+ /** Request/response ops (runtime.sendMessage). */
29
+ export const OP = {
30
+ STATUS: "status",
31
+ LIST_MODELS: "listModels",
32
+ LOAD: "load",
33
+ UNLOAD: "unload",
34
+ CHAT: "chat",
35
+ /** Many independent prompts at once; the engine fans them across the pool. */
36
+ BATCH: "batch",
37
+ CANCEL: "cancel",
38
+ /** Retune a live engine (currently: `decodeSteps`) without reloading weights. */
39
+ CONFIGURE: "configure",
40
+ };
41
+
42
+ /** Port ops (runtime.connect). */
43
+ export const PORT_OP = {
44
+ CHAT_STREAM: "chat.stream",
45
+ BATCH_STREAM: "batch.stream",
46
+ ABORT: "abort",
47
+ SUBSCRIBE: "subscribe",
48
+ CHUNK: "chunk",
49
+ ITEM: "item",
50
+ DONE: "done",
51
+ ERROR: "error",
52
+ ENGINE_STATE: "engineState",
53
+ };
54
+
55
+ export function request(op, payload = {}) {
56
+ return { protocol: PROTOCOL, op, ...payload };
57
+ }
src/adapters/webext.js ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * WebExtension adapter: the storage passthrough, and the message/port router.
3
+ *
4
+ * This is the whole extension-specific half of what used to be
5
+ * `src/background/background.js`. The engine no longer knows it exists — it is
6
+ * one way to reach a `ScheduledEngine`, and a page reaches the same object by
7
+ * calling its methods.
8
+ *
9
+ * The wire format is unchanged byte-for-byte from `everything-webgpu/v1`, so
10
+ * the popup, the manager page, the e2e devtest page and any external extension
11
+ * keep working with no edits.
12
+ */
13
+ import { asEngineError } from "../engine/errors.js";
14
+ import { OP, PORT_NAME, PORT_OP, PROTOCOL } from "./protocol.js";
15
+
16
+ /**
17
+ * `browser.storage.local` already *is* the StorageAdapter shape — `get(key)`
18
+ * and `set(obj)`. The two-method interface was chosen for that reason, so this
19
+ * is a passthrough rather than a translation layer.
20
+ *
21
+ * @returns {import("../engine/model-store.js").StorageAdapter}
22
+ */
23
+ export function webExtensionStorage() {
24
+ return browser.storage.local;
25
+ }
26
+
27
+ /**
28
+ * Wires a ScheduledEngine onto this extension's runtime messaging.
29
+ *
30
+ * @param {import("../engine/engine.js").ScheduledEngine} engine
31
+ * @returns {() => void} detach
32
+ */
33
+ export function attachWebExtensionTransport(engine) {
34
+ const subscribers = new Set();
35
+
36
+ const unsubscribe = engine.subscribe((state) => {
37
+ broadcast({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state });
38
+ });
39
+
40
+ function broadcast(msg) {
41
+ for (const port of subscribers) {
42
+ try {
43
+ port.postMessage(msg);
44
+ } catch {
45
+ subscribers.delete(port);
46
+ }
47
+ }
48
+ }
49
+
50
+ // ------------------------------------------------------------- routing ----
51
+
52
+ async function handle(msg) {
53
+ if (!msg || msg.protocol !== PROTOCOL) {
54
+ throw new Error(`Expected protocol "${PROTOCOL}", got "${msg && msg.protocol}".`);
55
+ }
56
+ switch (msg.op) {
57
+ case OP.STATUS:
58
+ return { ok: true, state: engine.state, webgpu: engine.hasWebGPU };
59
+ case OP.LIST_MODELS:
60
+ return { ok: true, models: await engine.listModels(), state: engine.state };
61
+ case OP.LOAD:
62
+ return { ok: true, state: await engine.load(msg.modelId) };
63
+ case OP.UNLOAD:
64
+ return { ok: true, state: await engine.unload() };
65
+ case OP.CHAT:
66
+ return { ok: true, ...(await engine.complete(msg)) };
67
+ case OP.BATCH:
68
+ return { ok: true, results: await engine.batch(msg) };
69
+ case OP.CANCEL:
70
+ return { ok: true, cancelled: engine.cancel(msg.id ?? msg.session) };
71
+ case OP.CONFIGURE:
72
+ return { ok: true, ...(await engine.configure(msg)) };
73
+ default:
74
+ throw new Error(`Unknown op "${msg.op}".`);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * `error` stays a plain string so every existing caller keeps working; `code`
80
+ * and `detail` are added beside it. Drop the string once consumers have
81
+ * moved — the wire protocol is versioned (`everything-webgpu/v1`) and this is
82
+ * an addition, not a break.
83
+ */
84
+ const respond = (msg) =>
85
+ handle(msg).catch((err) => {
86
+ const e = asEngineError(err);
87
+ return { ok: false, error: e.message, code: e.code, ...(e.detail ? { detail: e.detail } : {}) };
88
+ });
89
+
90
+ /**
91
+ * The allowlist is a property of *this transport*, not of the engine: it
92
+ * answers "which other extension may send me messages", a question that has
93
+ * no meaning for a page that already holds the object.
94
+ */
95
+ async function denyExternal(sender) {
96
+ const { allowedExternalIds } = await engine.store.getSettings();
97
+ const id = sender?.id;
98
+ if (allowedExternalIds.length === 0 || allowedExternalIds.includes(id)) return null;
99
+ return { ok: false, error: `Extension "${id}" is not on this engine's allowlist.` };
100
+ }
101
+
102
+ function attachPort(port) {
103
+ if (port.name !== PORT_NAME) return;
104
+ subscribers.add(port);
105
+ port.onDisconnect.addListener(() => subscribers.delete(port));
106
+ port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state: engine.state });
107
+
108
+ const send = (msg) => {
109
+ try {
110
+ port.postMessage(msg);
111
+ } catch {
112
+ /* port closed mid-stream */
113
+ }
114
+ };
115
+
116
+ port.onMessage.addListener(async (msg) => {
117
+ const id = msg?.id;
118
+ try {
119
+ if (!msg || msg.protocol !== PROTOCOL) throw new Error(`Expected protocol "${PROTOCOL}".`);
120
+ switch (msg.op) {
121
+ case PORT_OP.SUBSCRIBE:
122
+ return send({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state: engine.state });
123
+
124
+ case PORT_OP.CHAT_STREAM: {
125
+ const result = await engine.complete(msg, (delta) =>
126
+ send({ protocol: PROTOCOL, op: PORT_OP.CHUNK, id, delta }),
127
+ );
128
+ return send({ protocol: PROTOCOL, op: PORT_OP.DONE, id, ...result });
129
+ }
130
+
131
+ case PORT_OP.BATCH_STREAM: {
132
+ const results = await engine.batch(msg, (item) =>
133
+ send({ protocol: PROTOCOL, op: PORT_OP.ITEM, id, ...item }),
134
+ );
135
+ return send({ protocol: PROTOCOL, op: PORT_OP.DONE, id, results });
136
+ }
137
+
138
+ case PORT_OP.ABORT:
139
+ return void engine.cancel(msg.session ?? id);
140
+
141
+ default:
142
+ return send({ protocol: PROTOCOL, op: msg.op, id, ...(await respond(msg)) });
143
+ }
144
+ } catch (err) {
145
+ const e = asEngineError(err);
146
+ send({
147
+ protocol: PROTOCOL,
148
+ op: PORT_OP.ERROR,
149
+ id,
150
+ error: e.message,
151
+ code: e.code,
152
+ ...(e.detail ? { detail: e.detail } : {}),
153
+ });
154
+ }
155
+ });
156
+ }
157
+
158
+ const onMessage = (msg) => respond(msg);
159
+ const onMessageExternal = async (msg, sender) => (await denyExternal(sender)) ?? respond(msg);
160
+ const onConnectExternal = async (port) => {
161
+ const denial = await denyExternal(port.sender);
162
+ if (denial) {
163
+ port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ERROR, error: denial.error });
164
+ port.disconnect();
165
+ return;
166
+ }
167
+ attachPort(port);
168
+ };
169
+
170
+ browser.runtime.onMessage.addListener(onMessage);
171
+ browser.runtime.onMessageExternal.addListener(onMessageExternal);
172
+ browser.runtime.onConnect.addListener(attachPort);
173
+ browser.runtime.onConnectExternal.addListener(onConnectExternal);
174
+
175
+ return () => {
176
+ browser.runtime.onMessage.removeListener(onMessage);
177
+ browser.runtime.onMessageExternal.removeListener(onMessageExternal);
178
+ browser.runtime.onConnect.removeListener(attachPort);
179
+ browser.runtime.onConnectExternal.removeListener(onConnectExternal);
180
+ unsubscribe();
181
+ subscribers.clear();
182
+ };
183
+ }
src/background/background.html ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <meta charset="utf-8" />
3
+ <title>Everything WebGPU — engine host</title>
4
+ <script type="module" src="background.js"></script>
src/background/background.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The extension's engine host: build a ScheduledEngine, attach the transport.
3
+ *
4
+ * Everything that used to be in this file is now either engine (`src/engine/`)
5
+ * or transport (`src/adapters/webext.js`). What remains is the two lines that
6
+ * are genuinely specific to *this* host — which storage to use, and which
7
+ * transport to speak — and that is the whole shape a consumer of the library
8
+ * writes for their own app.
9
+ *
10
+ * The engine lives in the MV2 persistent background page because that is a real
11
+ * document on the extension origin, so it has both `navigator.gpu` and the same
12
+ * Cache Storage the manager page writes to. The model stays resident in VRAM
13
+ * across popup opens and across calls from other extensions.
14
+ */
15
+ import { ScheduledEngine } from "../engine/index.js";
16
+ import { attachWebExtensionTransport, webExtensionStorage } from "../adapters/webext.js";
17
+
18
+ export const engine = new ScheduledEngine({ store: webExtensionStorage() });
19
+
20
+ attachWebExtensionTransport(engine);
21
+
22
+ console.info("[Everything WebGPU] engine host ready; WebGPU present:", engine.hasWebGPU);
src/engine/chat.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `engine.chat.completions.create()` — the WebLLM-shaped facade.
3
+ *
4
+ * The point of this file is that migrating off `@mlc-ai/web-llm` costs one
5
+ * line. WebLLM is OpenAI-shaped, `buildParams()` already forwards `messages`,
6
+ * `temperature`, `max_tokens`, `response_format` and `extra_body` untouched, and
7
+ * everything this engine adds — the scheduler, multi-step decoding, the two
8
+ * build patches, the three model sources — sits behind that same call rather
9
+ * than beside it. So the facade is as thin as it can be: streamed chunks are
10
+ * WebLLM's own objects, passed through untouched, and only the non-streaming
11
+ * response is assembled here.
12
+ *
13
+ * `created` is therefore WebLLM's stable per-response value in milliseconds
14
+ * (OpenAI uses seconds; WebLLM does not, and the drop-in target is WebLLM), and
15
+ * the finish reasons are its own: `"stop" | "length" | "abort" | "tool_calls"`.
16
+ *
17
+ * `complete()` and `batch()` remain the direct API. They expose `cancelled` and
18
+ * `preempted` as first-class outcomes, which the OpenAI shape has no room for —
19
+ * both collapse to `finish_reason: "abort"` here, with the flags carried
20
+ * alongside for a caller that cares which happened.
21
+ */
22
+ import { ERROR, EngineError } from "./errors.js";
23
+
24
+ /** @param {import("./engine.js").ScheduledEngine} engine */
25
+ export function chatFacade(engine) {
26
+ return {
27
+ completions: {
28
+ /**
29
+ * @param {import("./engine.js").CompletionRequest & {
30
+ * stream?: boolean, stream_options?: {include_usage?: boolean} }} req
31
+ * @returns {Promise<object | AsyncIterable<object>>} a completion, or a
32
+ * stream of chunks when `stream` is set — the same two shapes WebLLM
33
+ * returns, so `await`ing then `for await`ing works unchanged.
34
+ */
35
+ async create(req) {
36
+ if (!Array.isArray(req?.messages) || req.messages.length === 0) {
37
+ throw new EngineError(ERROR.BAD_REQUEST, "`messages` must be a non-empty array.");
38
+ }
39
+ return req.stream ? streamCompletion(engine, req) : oneCompletion(engine, req);
40
+ },
41
+ },
42
+ };
43
+ }
44
+
45
+ const envelope = (engine, id, object) => ({
46
+ id,
47
+ object,
48
+ created: Date.now(),
49
+ model: engine.state.modelId,
50
+ });
51
+
52
+ async function oneCompletion(engine, req) {
53
+ const id = req.id ?? crypto.randomUUID();
54
+ const result = await engine.completeRaw(req);
55
+ return {
56
+ ...envelope(engine, id, "chat.completion"),
57
+ choices: [
58
+ {
59
+ index: 0,
60
+ // `content: null` beside tool_calls is WebLLM's own shape, not "".
61
+ message: result.toolCalls
62
+ ? { role: "assistant", content: null, tool_calls: result.toolCalls }
63
+ : { role: "assistant", content: result.text },
64
+ finish_reason: result.finishReason ?? "stop",
65
+ logprobs: null,
66
+ },
67
+ ],
68
+ usage: result.usage,
69
+ ...(result.cancelled ? { cancelled: true } : {}),
70
+ ...(result.preempted ? { preempted: true } : {}),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Bridges the raw chunk callback to an async iterator.
76
+ *
77
+ * Chunks pass through **verbatim**. WebLLM's are already compliant OpenAI
78
+ * envelopes carrying `id`, `created`, `model`, `logprobs`,
79
+ * `system_fingerprint` and the terminal `tool_calls`; the previous version
80
+ * rebuilt them from a bare string and lost all of that.
81
+ *
82
+ * Chunks are queued rather than awaited, because the engine must not be made to
83
+ * wait on a slow consumer: a stalled `for await` would hold a pool slot, and a
84
+ * pool slot is the scarce resource the whole scheduler exists to allocate. The
85
+ * queue is bounded in practice by `max_tokens`.
86
+ */
87
+ async function streamCompletion(engine, req) {
88
+ const id = req.id ?? crypto.randomUUID();
89
+
90
+ /** @type {object[]} */
91
+ const pending = [];
92
+ let wake = null;
93
+ /** @type {{result?: object, error?: unknown} | null} */
94
+ let settled = null;
95
+ const ping = () => {
96
+ const w = wake;
97
+ wake = null;
98
+ w?.();
99
+ };
100
+
101
+ engine
102
+ .completeRaw(req, (chunk) => {
103
+ pending.push(chunk);
104
+ ping();
105
+ })
106
+ .then(
107
+ (result) => {
108
+ settled = { result };
109
+ ping();
110
+ },
111
+ (error) => {
112
+ settled = { error };
113
+ ping();
114
+ },
115
+ );
116
+
117
+ return (async function* () {
118
+ for (;;) {
119
+ // Drain before checking `settled`, so the last chunks are never dropped
120
+ // by a generation that finished while they sat in the queue.
121
+ while (pending.length) yield pending.shift();
122
+ if (settled) break;
123
+ await new Promise((resolve) => (wake = resolve));
124
+ }
125
+
126
+ if (settled.error) throw settled.error;
127
+ const result = settled.result;
128
+
129
+ // Nothing is synthesized on the normal path: WebLLM emits its own terminal
130
+ // finish_reason chunk, and its own usage chunk when `include_usage` is set.
131
+ // An interrupted generation is the exception — the stream simply stops, so
132
+ // a consumer would otherwise never learn why.
133
+ if (result.cancelled || result.preempted) {
134
+ yield {
135
+ ...envelope(engine, id, "chat.completion.chunk"),
136
+ choices: [{ index: 0, delta: {}, finish_reason: "abort" }],
137
+ ...(result.cancelled ? { cancelled: true } : {}),
138
+ ...(result.preempted ? { preempted: true } : {}),
139
+ };
140
+ }
141
+ })();
142
+ }
src/engine/constants.js ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Engine vocabulary — the words the scheduler itself is defined in.
3
+ *
4
+ * Deliberately transport-free. A caller holding a `ScheduledEngine` uses these
5
+ * directly; a caller reaching the engine over a message port gets them
6
+ * re-exported from `src/adapters/protocol.js` alongside the wire ops. Keeping
7
+ * the split visible is the point: the wire protocol is one adapter, not the
8
+ * interface.
9
+ */
10
+
11
+ /**
12
+ * Scheduling bands, highest first. `interactive` may preempt a running job that
13
+ * opted into `preemptible`; nothing else ever interrupts work in flight.
14
+ */
15
+ export const PRIORITY = {
16
+ /** Ghost-text and anything else a human is waiting on keystroke-by-keystroke. */
17
+ INTERACTIVE: "interactive",
18
+ /** Default. Translation, one-shot answers. */
19
+ NORMAL: "normal",
20
+ /** Nobody is watching: reformatting, batch cleanup. Pair with `preemptible`. */
21
+ BACKGROUND: "background",
22
+ };
23
+
24
+ export const PRIORITY_ORDER = [PRIORITY.INTERACTIVE, PRIORITY.NORMAL, PRIORITY.BACKGROUND];
25
+
26
+ /**
27
+ * How far `unload()` goes.
28
+ *
29
+ * Two levels rather than two verbs because they are the same intention at
30
+ * different depths — "I am done with this model" — and the caller should not
31
+ * have to know that freeing VRAM and freeing disk are different subsystems.
32
+ * Forgetting the model *entirely* is `remove()`, which stays its own verb
33
+ * because it is the one that cannot be undone without re-supplying the source.
34
+ */
35
+ export const UNLOAD_LEVEL = {
36
+ /** Default. Free VRAM, keep the cached bytes — so reloading costs no network. */
37
+ VRAM: "vram",
38
+ /** Also delete the cached bytes. The registry entry survives, so the model is still known. */
39
+ CACHE: "cache",
40
+ };
41
+
42
+ /**
43
+ * What a queued job asks its engine to do.
44
+ *
45
+ * One pool, not two: priority, session supersession, preemption and
46
+ * one-task-one-engine are identical for both, and the only thing that differs
47
+ * is the call at the far end. A second pool would have duplicated the
48
+ * scheduler to change one line.
49
+ */
50
+ export const JOB_KIND = {
51
+ CHAT: "chat",
52
+ EMBEDDING: "embedding",
53
+ };
54
+
55
+ /** Engine lifecycle states. */
56
+ export const ENGINE_STATE = {
57
+ IDLE: "idle",
58
+ LOADING: "loading",
59
+ READY: "ready",
60
+ ERROR: "error",
61
+ };
62
+
63
+ /**
64
+ * Out-of-band message kind for configuring an engine worker before WebLLM's own
65
+ * handshake starts. Distinct from every WebLLM `kind`, so the worker can route
66
+ * on it without parsing the rest.
67
+ */
68
+ export const WORKER_CONFIGURE = "everything-webgpu/configure";
src/engine/create.js ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `CreateScheduledEngine` — the one-line migration off WebLLM.
3
+ *
4
+ * ```js
5
+ * -import { CreateMLCEngine } from "@mlc-ai/web-llm";
6
+ * -const engine = await CreateMLCEngine(modelId, { initProgressCallback });
7
+ * +import { CreateScheduledEngine } from "everything-webgpu";
8
+ * +const engine = await CreateScheduledEngine(modelId, { initProgressCallback });
9
+ * ```
10
+ *
11
+ * Everything after that line is unchanged: `engine.chat.completions.create()`
12
+ * takes and returns the same shapes. What the swap buys is the scheduler
13
+ * (`session`, `priority`, `task`, `preemptible` on any request), multi-step
14
+ * decoding, the two build-time patches, and models from disk as well as the
15
+ * network.
16
+ *
17
+ * The signature mirrors `CreateMLCEngine`'s so nothing else has to move —
18
+ * including `initProgressCallback`, which is why this is a function rather than
19
+ * documentation telling people to construct a `ScheduledEngine` themselves.
20
+ */
21
+ import { ScheduledEngine } from "./engine.js";
22
+ import { ERROR, EngineError } from "./errors.js";
23
+ import { ModelStore } from "./model-store.js";
24
+
25
+ /**
26
+ * @param {string} [modelId] loaded before returning, as `CreateMLCEngine` does.
27
+ * Omit to get an engine that loads later.
28
+ * @param {object} [opts]
29
+ * @param {ModelStore | import("./model-store.js").StorageAdapter} [opts.store]
30
+ * defaults to IndexedDB. Pass one explicitly in a worker or a test.
31
+ * @param {(report: {text: string, progress: number}) => void} [opts.initProgressCallback]
32
+ * @param {string | URL} [opts.workerUrl]
33
+ * @param {() => Promise<object>} [opts.loadWebLLM]
34
+ * @param {boolean} [opts.prebuilt]
35
+ * @returns {Promise<ScheduledEngine>}
36
+ */
37
+ export async function CreateScheduledEngine(modelId, opts = {}) {
38
+ const { store, initProgressCallback, ...rest } = opts;
39
+ const engine = new ScheduledEngine({ ...rest, store: store ?? (await defaultStore()) });
40
+ if (!modelId) return engine;
41
+
42
+ // Three states, not two. `undefined` means the caller did not choose, and the
43
+ // right default for a call that blocks for minutes on a multi-hundred-MB
44
+ // download is to say so: the first run of the three-line example otherwise
45
+ // looks exactly like a hung process, which is the worst possible first
46
+ // impression and the one thing no amount of documentation undoes. `null` is
47
+ // how you ask for silence.
48
+ const report =
49
+ initProgressCallback === undefined ? defaultProgressReporter(modelId) : initProgressCallback;
50
+
51
+ if (!report) {
52
+ await engine.load(modelId);
53
+ return engine;
54
+ }
55
+
56
+ // The engine reports progress through its own lifecycle stream; WebLLM
57
+ // reports it through a callback. Forward only the load reports, so a
58
+ // caller's callback fires exactly when WebLLM's would have.
59
+ const stop = engine.subscribe((state) => {
60
+ if (state.progress) report(state.progress);
61
+ });
62
+ try {
63
+ await engine.load(modelId);
64
+ } finally {
65
+ stop();
66
+ report.done?.();
67
+ }
68
+ return engine;
69
+ }
70
+
71
+ /**
72
+ * What you get when you say nothing: a throttled line on the console.
73
+ *
74
+ * Deliberately not the engine's own behaviour — `new ScheduledEngine()` stays
75
+ * silent, because a library core that logs is wrong inside a worker, an
76
+ * extension background page or a test. This is the getting-started facade, and
77
+ * the person calling it has not yet decided how to render anything.
78
+ *
79
+ * Throttled to one line a second, because WebLLM's callback fires per shard and
80
+ * a 58-shard model would otherwise bury the console. The 100% report is never
81
+ * dropped, so the last line always reads as finished rather than as 97%.
82
+ */
83
+ function defaultProgressReporter(modelId) {
84
+ if (typeof console === "undefined") return null;
85
+
86
+ const startedAt = Date.now();
87
+ let announced = false;
88
+ let lastAt = 0;
89
+ let sawProgress = false;
90
+
91
+ const emit = ({ text, progress }) => {
92
+ sawProgress = true;
93
+ if (!announced) {
94
+ announced = true;
95
+ console.info(
96
+ `[everything-webgpu] loading ${modelId} — the first run downloads the weights and can take ` +
97
+ "minutes; later loads read the cache and need no network. " +
98
+ "Pass initProgressCallback to render this yourself, or null to silence it.",
99
+ );
100
+ }
101
+ const now = Date.now();
102
+ const finished = (progress ?? 0) >= 1;
103
+ if (!finished && now - lastAt < 1000) return;
104
+ lastAt = now;
105
+ const pct = `${Math.round((progress ?? 0) * 100)}%`.padStart(4);
106
+ console.info(`[everything-webgpu] ${pct} ${text ?? ""}`.trimEnd());
107
+ };
108
+
109
+ // Called once when the load settles, so the common case ends on a line that
110
+ // says it worked rather than trailing off mid-progress.
111
+ emit.done = () => {
112
+ if (!sawProgress) return;
113
+ console.info(
114
+ `[everything-webgpu] ${modelId} ready in ${((Date.now() - startedAt) / 1000).toFixed(1)}s.`,
115
+ );
116
+ };
117
+ return emit;
118
+ }
119
+
120
+ /**
121
+ * IndexedDB, because a registry that dies with the page would strand the
122
+ * weights: the bytes stay in Cache Storage but nothing remembers they are
123
+ * there. Anywhere IndexedDB is missing, the caller has to say what to use —
124
+ * guessing would produce exactly that stranding, silently.
125
+ */
126
+ async function defaultStore() {
127
+ if (typeof indexedDB === "undefined") {
128
+ throw new EngineError(
129
+ ERROR.BAD_REQUEST,
130
+ "No IndexedDB in this context, so there is no default store. " +
131
+ "Pass `store` — `memoryStorage()` for a test, or your own StorageAdapter.",
132
+ );
133
+ }
134
+ // Imported lazily so the engine core carries no static dependency on an
135
+ // adapter, and a host that brings its own store never bundles this one.
136
+ const { indexedDBStorage } = await import("../adapters/idb.js");
137
+ return new ModelStore(await indexedDBStorage());
138
+ }
src/engine/device.js ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * "Will this model run here, and if not, why not" — answered before a byte is
3
+ * fetched.
4
+ *
5
+ * Every input is readable up front: WebGPU presence, whether an adapter can be
6
+ * had at all, `shader-f16`, five adapter limits, and the storage quota. So the
7
+ * expensive failure — download a gigabyte, then fail at pipeline creation — is
8
+ * avoidable, and this file exists to avoid it.
9
+ *
10
+ * It is also where this project's hard-won platform knowledge is written down
11
+ * as code rather than prose. Each rule below is a failure that actually
12
+ * happened here: the blocklisted GPU, Firefox's 9-storage-buffer cap that kills
13
+ * paged prefill, `q4f16_1` on a device without f16.
14
+ *
15
+ * What it deliberately does **not** claim: how much VRAM is free. Firefox
16
+ * implements neither `navigator.deviceMemory` nor `performance.memory`, and
17
+ * `storage.estimate()` measures disk quota, not memory. Nothing reports free
18
+ * memory to a web page. So VRAM headroom is reported as a warning with the
19
+ * numbers that *are* known, never as a confident blocker — the same reason the
20
+ * pool probes for a second engine rather than predicting one (AI.md, "The pool
21
+ * grows, it is not sized").
22
+ */
23
+
24
+ /** WebLLM's ModelType enum; see model-store.js. */
25
+ const MODEL_TYPE_LLM = 0;
26
+ const MODEL_TYPE_VLM = 2;
27
+
28
+ /** Bindings `batch_prefill_paged_kv_kernel` needs; below this, no KV reuse. */
29
+ const PAGED_PREFILL_STORAGE_BUFFERS = 10;
30
+
31
+ /**
32
+ * @typedef {object} DeviceProbe
33
+ * @property {boolean} webgpu
34
+ * @property {string} [reason] why WebGPU is unusable, when it is
35
+ * @property {object} [adapter] vendor / architecture / device, where exposed
36
+ * @property {{shaderF16: boolean}} [features]
37
+ * @property {object} [limits]
38
+ * @property {boolean} [kvReuse] whether cross-turn KV reuse can be used here
39
+ * @property {{quota?: number, usage?: number, persisted?: boolean}} storage
40
+ * @property {number} [deviceMemoryGB] Chrome only; absent is not "small"
41
+ */
42
+
43
+ /**
44
+ * Reads what this machine will admit to. Never throws: an unusable device is a
45
+ * result, not an error — the caller's job is to explain it, not to crash.
46
+ *
47
+ * @returns {Promise<DeviceProbe>}
48
+ */
49
+ export async function probeDevice() {
50
+ const storage = await probeStorage();
51
+ const gpu = globalThis.navigator?.gpu;
52
+
53
+ if (!gpu) {
54
+ return {
55
+ webgpu: false,
56
+ reason:
57
+ "navigator.gpu is absent. On Firefox set dom.webgpu.enabled=true in about:config and restart; " +
58
+ "on any browser, a non-secure context (plain http) also hides it.",
59
+ storage,
60
+ };
61
+ }
62
+
63
+ // A `navigator.gpu` that is present but incomplete — a polyfill, a shim, a
64
+ // partially-enabled build — must be a result like any other, not a TypeError
65
+ // out of a function documented never to throw.
66
+ const adapter =
67
+ typeof gpu.requestAdapter === "function" ? await gpu.requestAdapter().catch(() => null) : null;
68
+ if (!adapter) {
69
+ return {
70
+ webgpu: false,
71
+ reason:
72
+ "WebGPU is present but no adapter was granted — usually a blocklisted GPU. On Firefox try " +
73
+ "gfx.webgpu.ignore-blocklist=true in about:config.",
74
+ storage,
75
+ };
76
+ }
77
+
78
+ const limits = {};
79
+ for (const key of [
80
+ "maxBufferSize",
81
+ "maxStorageBufferBindingSize",
82
+ "maxStorageBuffersPerShaderStage",
83
+ "maxComputeInvocationsPerWorkgroup",
84
+ "maxComputeWorkgroupStorageSize",
85
+ ]) {
86
+ limits[key] = adapter.limits?.[key];
87
+ }
88
+
89
+ return {
90
+ webgpu: true,
91
+ adapter: await adapterInfo(adapter),
92
+ features: { shaderF16: Boolean(adapter.features?.has?.("shader-f16")) },
93
+ limits,
94
+ // Below 10 bindings the paged-prefill pipeline cannot be built, so every
95
+ // turn re-prefills the whole history. See engine-worker.js.
96
+ kvReuse: (limits.maxStorageBuffersPerShaderStage ?? 0) >= PAGED_PREFILL_STORAGE_BUFFERS,
97
+ storage,
98
+ ...(globalThis.navigator?.deviceMemory ? { deviceMemoryGB: navigator.deviceMemory } : {}),
99
+ };
100
+ }
101
+
102
+ /** `adapter.info` is the current spec; `requestAdapterInfo()` was the old one. */
103
+ /**
104
+ * What the adapter will admit about itself, with blanks dropped.
105
+ *
106
+ * Firefox 154 exposes `adapter.info` but fills every field with `""`, so the
107
+ * naive shape is an object that *looks* populated and renders as "GPU: ". A
108
+ * caller cannot tell that from a real answer without checking each string, so
109
+ * empty fields are omitted and a browser that says nothing yields `{}` — the
110
+ * same thing the no-info path already returns.
111
+ */
112
+ async function adapterInfo(adapter) {
113
+ const info = adapter.info ?? (await adapter.requestAdapterInfo?.().catch(() => null));
114
+ if (!info) return {};
115
+ const { vendor, architecture, device, description } = info;
116
+ return Object.fromEntries(
117
+ Object.entries({ vendor, architecture, device, description }).filter(([, v]) => v),
118
+ );
119
+ }
120
+
121
+ async function probeStorage() {
122
+ const s = globalThis.navigator?.storage;
123
+ if (!s) return {};
124
+ const [estimate, persisted] = await Promise.all([
125
+ s.estimate?.().catch(() => ({})) ?? {},
126
+ s.persisted?.().catch(() => undefined) ?? undefined,
127
+ ]);
128
+ return { quota: estimate?.quota, usage: estimate?.usage, persisted };
129
+ }
130
+
131
+ /**
132
+ * Whether a model can run on a probed device.
133
+ *
134
+ * `blockers` mean it will not work; `warnings` mean it will work worse, or
135
+ * might not fit. The split matters: a caller should refuse to start on a
136
+ * blocker and merely inform on a warning, and conflating the two is how you end
137
+ * up either crashing or refusing to run something that would have been fine.
138
+ *
139
+ * @param {{model_id: string, vram_required_MB?: number, sizeBytes?: number}} model
140
+ * @param {DeviceProbe} probe
141
+ * @returns {{ok: boolean, blockers: Array<{code: string, message: string}>,
142
+ * warnings: Array<{code: string, message: string}>}}
143
+ */
144
+ export function canRun(model, probe) {
145
+ const blockers = [];
146
+ const warnings = [];
147
+ const say = (list, code, message) => list.push({ code, message });
148
+
149
+ if (!probe?.webgpu) {
150
+ say(blockers, "NO_WEBGPU", probe?.reason ?? "WebGPU is unavailable.");
151
+ return { ok: false, blockers, warnings };
152
+ }
153
+
154
+ // The quantisation is encoded in the model id by MLC convention
155
+ // (`…-q4f16_1-MLC`). It is a naming convention, not a manifest field, so this
156
+ // is a heuristic — but a wrong guess only costs a spurious warning, while not
157
+ // checking costs a gigabyte downloaded before a pipeline fails.
158
+ if (/f16/.test(model?.model_id ?? "") && !probe.features?.shaderF16) {
159
+ say(
160
+ blockers,
161
+ "NO_SHADER_F16",
162
+ `"${model.model_id}" is an f16 build and this device has no \`shader-f16\` feature. ` +
163
+ "Choose a q4f32_1 or q0f32 variant — they are larger and slower, but they will run.",
164
+ );
165
+ }
166
+
167
+ if (!probe.kvReuse) {
168
+ say(
169
+ warnings,
170
+ "NO_KV_REUSE",
171
+ `This device allows ${probe.limits?.maxStorageBuffersPerShaderStage} storage buffers per shader ` +
172
+ `stage; paged prefill needs ${PAGED_PREFILL_STORAGE_BUFFERS}. Every turn re-prefills the whole ` +
173
+ "history, so long conversations get slow first tokens.",
174
+ );
175
+ }
176
+
177
+ const vramMB = model?.vram_required_MB;
178
+ if (vramMB && probe.deviceMemoryGB && vramMB > probe.deviceMemoryGB * 1024 * 0.5) {
179
+ say(
180
+ warnings,
181
+ "TIGHT_MEMORY",
182
+ `The model wants ~${Math.round(vramMB)} MB and this device reports ${probe.deviceMemoryGB} GB of ` +
183
+ "memory. It may load and then run against swap, or fail to load a second engine.",
184
+ );
185
+ }
186
+
187
+ const bytes = model?.sizeBytes ?? (vramMB ? vramMB * 1024 * 1024 : 0);
188
+ const free = probe.storage?.quota != null ? probe.storage.quota - (probe.storage.usage ?? 0) : null;
189
+ if (bytes && free != null && bytes > free) {
190
+ say(
191
+ blockers,
192
+ "INSUFFICIENT_STORAGE",
193
+ `The model needs ~${Math.round(bytes / 1e6)} MB cached and only ~${Math.round(free / 1e6)} MB of ` +
194
+ "quota is free.",
195
+ );
196
+ }
197
+
198
+ if (bytes && probe.storage?.persisted === false) {
199
+ say(
200
+ warnings,
201
+ "NOT_PERSISTED",
202
+ "Storage is not persisted, so the browser may evict the model under disk pressure. " +
203
+ "Call ensurePersistent() to ask for it.",
204
+ );
205
+ }
206
+
207
+ return { ok: blockers.length === 0, blockers, warnings };
208
+ }
209
+
210
+ /**
211
+ * Rank a model list by what this device can actually run.
212
+ *
213
+ * The prebuilt list spans 239 MB to 31 GB, so "which model should I use" is the
214
+ * first question a developer has and the one they have least basis to answer.
215
+ * Runnable models come first, then fewest warnings; unrunnable ones are kept at
216
+ * the end carrying their reason rather than silently dropped, because "why
217
+ * can't I use that one" is the next question.
218
+ *
219
+ * **`prefer` is a real choice, not a default worth hiding.** Decode here is
220
+ * memory-bandwidth-bound — time per token scales with weight bytes (AI.md,
221
+ * "Why not llama.cpp/Ollama-class"), so the largest model that fits is also the
222
+ * slowest thing that fits. `"quality"` picks the biggest, `"speed"` the
223
+ * smallest. Neither is right for everyone, which is why it is a parameter.
224
+ *
225
+ * Vision models are excluded from a text ranking rather than merely deprioritised:
226
+ * a VLM answers text prompts perfectly well, but at several times the download
227
+ * for no benefit, so recommending one to a caller who did not ask is bad advice.
228
+ *
229
+ * @param {Array<object>} models `model_list` entries or registry records
230
+ * @param {{probe: DeviceProbe, maxVramMB?: number, needsVision?: boolean,
231
+ * prefer?: "quality" | "speed"}} opts
232
+ */
233
+ export function rankModels(models, { probe, maxVramMB, needsVision = false, prefer = "quality" } = {}) {
234
+ const wanted = needsVision ? MODEL_TYPE_VLM : MODEL_TYPE_LLM;
235
+ return models
236
+ .filter((m) => (m.model_type ?? MODEL_TYPE_LLM) === wanted)
237
+ .filter((m) => !maxVramMB || !m.vram_required_MB || m.vram_required_MB <= maxVramMB)
238
+ .map((m) => ({ model: m, ...canRun(m, probe) }))
239
+ .sort((a, b) => {
240
+ if (a.ok !== b.ok) return a.ok ? -1 : 1;
241
+ if (a.warnings.length !== b.warnings.length) return a.warnings.length - b.warnings.length;
242
+ const sizeA = a.model.vram_required_MB ?? 0;
243
+ const sizeB = b.model.vram_required_MB ?? 0;
244
+ return prefer === "speed" ? sizeA - sizeB : sizeB - sizeA;
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Decode throughput is memory bandwidth divided by weight bytes.
250
+ *
251
+ * This project measured the whole chain: decode reaches ~16 GB/s of the M4's
252
+ * ~120 GB/s, and the 1.06 GB build runs 16.6–18.1 tok/s — which is that
253
+ * quotient. So a projection needs one number, the *achieved* bandwidth, and
254
+ * everything else follows from model size.
255
+ *
256
+ * The constant below is that machine's figure and is only a starting point. The
257
+ * moment this engine has decoded anything it knows the real number for the
258
+ * machine it is on, and `ScheduledEngine.estimateSpeed()` switches to it — so
259
+ * this is a cold-start default, not a claim about anyone's hardware.
260
+ */
261
+ export const REFERENCE_DECODE_BYTES_PER_SECOND = 17e9;
262
+ export const REFERENCE_DEVICE = "M4 MacBook Air (16 GB), Firefox";
263
+
264
+ /**
265
+ * @param {number} modelBytes
266
+ * @param {number} [bytesPerSecond] this machine's measured rate, when known
267
+ * @returns {{tokensPerSecond: number, basis: "measured" | "extrapolated",
268
+ * modelBytes: number, bytesPerSecond: number, reference?: string}}
269
+ */
270
+ export function projectSpeed(modelBytes, bytesPerSecond) {
271
+ const rate = bytesPerSecond || REFERENCE_DECODE_BYTES_PER_SECOND;
272
+ return {
273
+ tokensPerSecond: modelBytes > 0 ? rate / modelBytes : 0,
274
+ basis: bytesPerSecond ? "measured" : "extrapolated",
275
+ modelBytes,
276
+ bytesPerSecond: rate,
277
+ ...(bytesPerSecond ? {} : { reference: REFERENCE_DEVICE }),
278
+ };
279
+ }
src/engine/engine-worker.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * One pool slot's engine, in its own JS realm.
3
+ *
4
+ * Several MLCEngines cannot share a realm. Isolated by running the same e2e
5
+ * three ways against Qwen3.5-0.8B:
6
+ *
7
+ * 1 engine, background page -> passes
8
+ * 2 engines, background page -> both load, the first generates
9
+ * fine, the second's first
10
+ * generation fails with
11
+ * "Expected null or instance of
12
+ * VectorInt, got an instance of
13
+ * VectorInt"
14
+ * 2 engines, one worker each -> passes
15
+ *
16
+ * So the trigger is a second engine generating in the same realm, not the pool
17
+ * or the engine count as such. That error is embind reporting a type-registry
18
+ * mismatch, and the bundle does carry module-scoped emscripten state
19
+ * (`var Module`, `var __wasmLib`) shared by every instance, which fits — but
20
+ * the fix rests on the isolation above, not on having traced the registry.
21
+ *
22
+ * Workers are viable because Firefox exposes WebGPU to dedicated workers and
23
+ * the 100 ms completion tick is shared across them, so the concurrency win
24
+ * survives the move off the main thread (measured: 4 workers, 36.3 syncs/s).
25
+ *
26
+ * The realm is also where the decode loop lives, so it is where multi-step
27
+ * decoding is installed — the background page only ever holds a proxy.
28
+ */
29
+ import { WebWorkerMLCEngineHandler } from "../../vendor/web-llm.js";
30
+ import { WORKER_CONFIGURE } from "./constants.js";
31
+ import { DEFAULT_DECODE_STEPS, installMultiStepDecoding } from "./multistep.js";
32
+
33
+ const handler = new WebWorkerMLCEngineHandler();
34
+
35
+ /**
36
+ * Running totals for the decode probe (AI.md, "Where the 46 ms goes").
37
+ *
38
+ * `encodeMs` is content-process CPU — command encoding, `createBindGroup`, IPC.
39
+ * `syncMs` is GPU execution plus the wait for Firefox's 100 ms poll tick. They
40
+ * are measured on either side of the burst's single `await`, so together they
41
+ * partition the decode budget with nothing unaccounted for.
42
+ */
43
+ const stats = {
44
+ bursts: 0,
45
+ tokens: 0,
46
+ encodeMs: 0,
47
+ syncMs: 0,
48
+ dispatches: 0,
49
+ forwardDispatches: 0,
50
+ flushes: 0,
51
+ };
52
+ const resetStats = () => Object.keys(stats).forEach((k) => (stats[k] = 0));
53
+
54
+ /**
55
+ * Set when a pipeline fails the multi-step contract, and never cleared — a
56
+ * retune resets the measurement window, not the fact that the fast path is off.
57
+ */
58
+ let multiStepOff = null;
59
+
60
+ // No `kind` field: WebLLM's client handler ignores messages it cannot classify
61
+ // instead of throwing UnknownMessageKindError.
62
+ const postStats = () =>
63
+ postMessage({ ewgpuStats: { ...stats, steps: multiStep.steps, multiStepOff } });
64
+
65
+ const multiStep = installMultiStepDecoding(handler.engine, {
66
+ steps: DEFAULT_DECODE_STEPS,
67
+ onBurst: (b) => {
68
+ stats.bursts += 1;
69
+ stats.tokens += b.tokens;
70
+ stats.encodeMs += b.encodeMs ?? 0;
71
+ stats.syncMs += b.syncMs ?? 0;
72
+ stats.dispatches += b.dispatches ?? 0;
73
+ stats.forwardDispatches += b.forwardDispatches ?? 0;
74
+ stats.flushes += b.flushes ?? 0;
75
+ postStats();
76
+ },
77
+ // The only message that can ever report this. When the fast path is off there
78
+ // are no bursts, so `onBurst` never fires and the decode probe simply stops
79
+ // arriving — indistinguishable, from the host's side, from an idle engine.
80
+ onFallback: ({ missing }) => {
81
+ multiStepOff = missing;
82
+ postStats();
83
+ },
84
+ });
85
+
86
+ /**
87
+ * Force a full re-prefill instead of reusing the KV cache across rounds.
88
+ *
89
+ * Multi-round reuse routes attention through `batch_prefill_paged_kv_kernel`,
90
+ * which binds 10 storage buffers: q, pages, lse, output and six small i32
91
+ * metadata arrays. Firefox's Metal backend caps `maxStorageBuffersPerShaderStage`
92
+ * at 9, so that pipeline fails to build — and an invalid WebGPU pipeline is
93
+ * silent, its dispatches becoming no-ops. The symptom is a second turn that
94
+ * answers the *previous* question behind a garbage prefix that differs run to
95
+ * run, which is uninitialised memory being read.
96
+ *
97
+ * Resetting the conversation first makes WebLLM's own conversation comparison
98
+ * fail, so it re-prefills from scratch through `batch_prefill_ragged_kv_kernel`
99
+ * (9 bindings, works). The cost is re-reading the history each turn; prefill is
100
+ * one sync per chunk, so it is far cheaper than the garbage it replaces.
101
+ *
102
+ * Conditional on the limit, not on the browser: a device that allows 10 keeps
103
+ * the KV cache and the faster path.
104
+ */
105
+ /** Bindings `batch_prefill_paged_kv_kernel` needs; see tools/audit-wasm.mjs. */
106
+ const PAGED_PREFILL_STORAGE_BUFFERS = 10;
107
+
108
+ /** Whether this device is too tight to build that pipeline. Probed once. */
109
+ const kvReuseUnsafe = (async () => {
110
+ const adapter = await navigator.gpu?.requestAdapter().catch(() => null);
111
+ const limit = adapter?.limits?.maxStorageBuffersPerShaderStage ?? 0;
112
+ const unsafe = limit < PAGED_PREFILL_STORAGE_BUFFERS;
113
+ if (unsafe) {
114
+ console.info(
115
+ `[everything-webgpu] KV reuse disabled: paged prefill needs ` +
116
+ `${PAGED_PREFILL_STORAGE_BUFFERS} storage buffers, this device allows ${limit}`,
117
+ );
118
+ }
119
+ return unsafe;
120
+ })();
121
+
122
+ // Wrapped synchronously at module load: deciding first and wrapping after the
123
+ // await would leave a window where an early prefill slips through unguarded.
124
+ const basePrefill = handler.engine.prefill.bind(handler.engine);
125
+ handler.engine.prefill = async (input, pipeline, chatConfig, genConfig) => {
126
+ if (await kvReuseUnsafe) pipeline.resetChat(/* keepStats= */ true);
127
+ return basePrefill(input, pipeline, chatConfig, genConfig);
128
+ };
129
+
130
+ // The engine is reachable before any model is loaded, so the host can set the
131
+ // step count on the very first message and never has to reload to change it.
132
+ self.onmessage = (msg) => {
133
+ if (msg.data?.kind === WORKER_CONFIGURE) {
134
+ if (msg.data.decodeSteps !== undefined) multiStep.setSteps(msg.data.decodeSteps);
135
+ // Every retune starts a fresh measurement window, so a sweep's points never
136
+ // bleed into each other.
137
+ resetStats();
138
+ postStats();
139
+ return;
140
+ }
141
+ handler.onmessage(msg);
142
+ };
src/engine/engine.js ADDED
@@ -0,0 +1,1336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The engine, with no transport attached.
3
+ *
4
+ * This is what a developer embedding a local model in their own page holds:
5
+ * one object, plain async methods, no message vocabulary. `browser.runtime` is
6
+ * not referenced anywhere in this file or anything it imports — the WebExtension
7
+ * message/port router that used to live here now sits behind
8
+ * `src/adapters/webext.js` and calls these same methods.
9
+ *
10
+ * Everything runs through one EnginePool, which owns priority, cancellation and
11
+ * fan-out. Nothing here decides what runs when.
12
+ *
13
+ * Three things are injected, and they are exactly the three places the host
14
+ * environment leaked into the engine:
15
+ *
16
+ * - `store` a ModelStore over a StorageAdapter, because
17
+ * `browser.storage.local` does not exist in a page.
18
+ * - `workerUrl` defaults to `new URL("./engine-worker.js", import.meta.url)`,
19
+ * which Vite, webpack 5 and esbuild all understand and which
20
+ * also resolves correctly on `moz-extension://`. It replaces
21
+ * `browser.runtime.getURL` rather than sitting beside it.
22
+ * - `loadWebLLM` defaults to a static relative `import()`, so bundlers can see
23
+ * it, and a host with its own patched build can override it.
24
+ * It stays *dynamic* on purpose: the ~6 MB WebLLM bundle is
25
+ * fetched when a model is loaded, not when the page paints.
26
+ *
27
+ * Weights arrive by any of three routes — prebuilt (WebLLM's HuggingFace list),
28
+ * remote (`registerModel` with any base URL you host), or injected
29
+ * (`ingestModelFolder`, no network at all). `load()` resolves across all three;
30
+ * see `model-store.js`. Pass `prebuilt: false` for a build that must never
31
+ * reach the network for a model.
32
+ */
33
+ import { ENGINE_STATE, JOB_KIND, PRIORITY, UNLOAD_LEVEL, WORKER_CONFIGURE } from "./constants.js";
34
+ import { chatFacade } from "./chat.js";
35
+ import { environmentFacade } from "./environment.js";
36
+ import { canRun, probeDevice, projectSpeed, rankModels } from "./device.js";
37
+ import { ERROR, EngineError, asEngineError } from "./errors.js";
38
+ import { filesFromDataTransfer, filesFromInput, ingestModelFolder } from "./ingest.js";
39
+ import { ModelStore, SOURCE, groupKeysByScope, isInjected, toAppConfig } from "./model-store.js";
40
+ import { clampSteps } from "./multistep.js";
41
+ import { EnginePool } from "./pool.js";
42
+ import { prefetchModel } from "./prefetch.js";
43
+ import { ask, conversation, ghostText } from "./recipes.js";
44
+ import { SOURCE_KIND, classifySource, isDataTransfer, isFileList, nearMatches } from "./sources.js";
45
+
46
+ /**
47
+ * @typedef {object} CompletionRequest
48
+ * The OpenAI generation fields WebLLM already speaks, plus the scheduling
49
+ * fields that are what this engine adds over calling WebLLM directly.
50
+ * @property {Array<{role: string, content: string}>} messages
51
+ * @property {string} [modelId] load this model first if it is not the live one
52
+ * @property {string} [id] job id; also what `cancel(id)` takes
53
+ * @property {number} [temperature]
54
+ * @property {number} [max_tokens]
55
+ * @property {object} [response_format]
56
+ * @property {object} [extra_body]
57
+ * @property {string} [task] the unit that owns an engine; a whole batch shares one
58
+ * @property {string} [session] a later job with this key supersedes the earlier one
59
+ * @property {"interactive"|"normal"|"background"} [priority]
60
+ * @property {boolean} [preemptible] may be interrupted by an `interactive` job
61
+ */
62
+
63
+ /**
64
+ * @typedef {object} CompletionResult
65
+ * @property {string} text
66
+ * @property {object} [usage]
67
+ * @property {"stop"|"length"|"abort"} [finishReason] WebLLM's own values
68
+ * @property {true} [cancelled] superseded or explicitly cancelled
69
+ * @property {true} [preempted] an `interactive` job took the slot; `text` is partial
70
+ */
71
+
72
+ /**
73
+ * @typedef {CompletionRequest & {index: number, engineIndex: number,
74
+ * startedAt: number, finishedAt: number, error?: string}} BatchItem
75
+ */
76
+
77
+ const DEFAULT_WORKER_URL = () => new URL("./engine-worker.js", import.meta.url);
78
+ const DEFAULT_LOAD_WEBLLM = () => import("../../vendor/web-llm.js");
79
+
80
+ /**
81
+ * Turn "the package is not wired into your build" into a sentence that says so.
82
+ *
83
+ * `vendor/web-llm.js` is a **build product**, not a checked-in file, so the two
84
+ * ways to arrive here are both install-shaped rather than runtime-shaped: a git
85
+ * dependency whose `prepare` never ran, or a source checkout where `npm run
86
+ * build` was never run. Left alone this surfaced as a bare
87
+ * `Cannot find module '.../vendor/web-llm.js'` under the code
88
+ * `GENERATION_FAILED` — wrong twice over, since nothing had begun generating
89
+ * and the path named is ours, not the caller's.
90
+ */
91
+ async function loadBundle(loadWebLLM) {
92
+ try {
93
+ return await loadWebLLM();
94
+ } catch (err) {
95
+ const message = String(err?.message ?? err);
96
+ // Only a resolution failure means "not built". A bundle that throws while
97
+ // *evaluating* is a real crash and must keep its own stack.
98
+ if (!/Cannot find module|Failed to (fetch|resolve)|ERR_MODULE_NOT_FOUND|dynamically imported module/i.test(message)) {
99
+ throw err;
100
+ }
101
+ throw new EngineError(
102
+ ERROR.PACKAGE_INCOMPLETE,
103
+ "everything-webgpu is installed but its WebLLM bundle (vendor/web-llm.js) is missing. " +
104
+ "That file is generated, not checked in — run `npm run build` in the package, " +
105
+ "or reinstall so its `prepare` script runs.",
106
+ { cause: "vendor-bundle-missing", underlying: message },
107
+ );
108
+ }
109
+ }
110
+
111
+ export class ScheduledEngine {
112
+ #store;
113
+ #workerUrl;
114
+ #loadWebLLM;
115
+ #prebuilt;
116
+ #chat = null;
117
+ #environment = null;
118
+ #probe = null;
119
+ /** This machine's achieved decode bandwidth, learned from the first generation. */
120
+ #decodeBytesPerSecond = 0;
121
+ /** modelId -> weight bytes, for projections. */
122
+ #modelBytes = new Map();
123
+ /**
124
+ * Resident models: modelId -> EnginePool. More than one may be up at once —
125
+ * a text model beside a vision model, say — which is why this is a map and
126
+ * not a field. Each entry holds a full copy of its weights, so residency is
127
+ * budget-gated in `load()`.
128
+ * @type {Map<string, EnginePool>}
129
+ */
130
+ #pools = new Map();
131
+ /** Which resident model an unaddressed request goes to. */
132
+ #current = null;
133
+ /** In-flight loads, per model, so two models can come up concurrently. */
134
+ #loading = new Map();
135
+ /**
136
+ * Pools that are still loading. `load()` holds the pool in a local until it
137
+ * is ready, which left an in-flight download unreachable — so aborting one
138
+ * had nothing to tear down. See `load({ signal })`.
139
+ * @type {Map<string, EnginePool>}
140
+ */
141
+ #loadingPools = new Map();
142
+ #listeners = new Set();
143
+
144
+ #state = {
145
+ status: ENGINE_STATE.IDLE,
146
+ modelId: null,
147
+ progress: null,
148
+ error: null,
149
+ pool: { size: 0, busy: 0, queued: 0 },
150
+ /** Model ids with a live pool. `modelId` is whichever of them is current. */
151
+ resident: [],
152
+ /** Latest decode probe from an engine worker; see multistep.js. */
153
+ decode: null,
154
+ };
155
+
156
+ /**
157
+ * @param {object} opts
158
+ * @param {ModelStore | import("./model-store.js").StorageAdapter} opts.store
159
+ * a ModelStore, or a bare StorageAdapter to wrap in one
160
+ * @param {string | URL} [opts.workerUrl]
161
+ * @param {() => Promise<object>} [opts.loadWebLLM]
162
+ * @param {boolean} [opts.prebuilt] expose WebLLM's 163 HuggingFace-hosted
163
+ * models, downloaded on first load. Default true. Set false for an
164
+ * offline-only build: `load()` then resolves registered models and nothing
165
+ * else, and an unknown id fails before the WebLLM bundle is even fetched.
166
+ */
167
+ constructor({ store, workerUrl, loadWebLLM, prebuilt = true } = {}) {
168
+ if (!store) {
169
+ throw new EngineError(ERROR.BAD_REQUEST, "ScheduledEngine needs a `store` (ModelStore or StorageAdapter).");
170
+ }
171
+ this.#store = store instanceof ModelStore ? store : new ModelStore(store);
172
+ this.#workerUrl = workerUrl ?? DEFAULT_WORKER_URL();
173
+ // Wrapped once here rather than at each of the seven `#loadWebLLM()` call
174
+ // sites: a missing bundle is the same failure whichever verb reached it
175
+ // first, and a site added later gets the good error for free.
176
+ const load = loadWebLLM ?? DEFAULT_LOAD_WEBLLM;
177
+ this.#loadWebLLM = () => loadBundle(load);
178
+ this.#prebuilt = prebuilt;
179
+ }
180
+
181
+ /** The ModelStore, so a host can drive the registry without a second handle. */
182
+ get store() {
183
+ return this.#store;
184
+ }
185
+
186
+ /**
187
+ * `chat.completions.create()`, the WebLLM/OpenAI shape. See `chat.js`.
188
+ *
189
+ * Built once and cached: callers hold on to `engine.chat.completions` the way
190
+ * they did with WebLLM, and a fresh object each access would break that.
191
+ */
192
+ get chat() {
193
+ this.#chat ??= chatFacade(this);
194
+ return this.#chat;
195
+ }
196
+
197
+ /**
198
+ * `environment()` — the read-only report, with `environment.measure()` on it.
199
+ *
200
+ * Cached like `chat` so a caller can hold on to it. Writes are `configure()`;
201
+ * see `environment.js` for why those are separate verbs.
202
+ */
203
+ get environment() {
204
+ this.#environment ??= environmentFacade(this);
205
+ return this.#environment;
206
+ }
207
+
208
+ get state() {
209
+ return { ...this.#state };
210
+ }
211
+
212
+ get hasWebGPU() {
213
+ return Boolean(globalThis.navigator?.gpu);
214
+ }
215
+
216
+ /**
217
+ * @param {(state: object) => void} listener called immediately, then on change
218
+ * @returns {() => void} unsubscribe
219
+ */
220
+ subscribe(listener) {
221
+ this.#listeners.add(listener);
222
+ listener(this.state);
223
+ return () => this.#listeners.delete(listener);
224
+ }
225
+
226
+ /** The current model's pool, or null. */
227
+ get #pool() {
228
+ return this.#current ? (this.#pools.get(this.#current) ?? null) : null;
229
+ }
230
+
231
+ /** Model ids with a live pool right now. */
232
+ get resident() {
233
+ return [...this.#pools.keys()];
234
+ }
235
+
236
+ /**
237
+ * Choose which resident model unaddressed requests go to.
238
+ *
239
+ * Distinct from `load()` on purpose: this is free and instant, because the
240
+ * weights are already up. `load()` is what costs.
241
+ */
242
+ use(modelId) {
243
+ if (!this.#pools.has(modelId)) {
244
+ throw new EngineError(
245
+ ERROR.UNKNOWN_MODEL,
246
+ `"${modelId}" is not resident. Resident: ${this.resident.join(", ") || "none"}. Call load() first.`,
247
+ { modelId, resident: this.resident },
248
+ );
249
+ }
250
+ this.#current = modelId;
251
+ this.#syncState();
252
+ return this.state;
253
+ }
254
+
255
+ /** Registered models only — cheap, no bundle load. */
256
+ listModels() {
257
+ return this.#store.list();
258
+ }
259
+
260
+ /**
261
+ * Everything `load()` would accept, normalised: registered models first, then
262
+ * WebLLM's prebuilt list.
263
+ *
264
+ * Costs a WebLLM bundle fetch when `prebuilt` is on, because the list lives
265
+ * inside it. `listModels()` is the cheap call if you only care about what this
266
+ * app registered.
267
+ *
268
+ * @returns {Promise<Array<{modelId: string, source: string, model: string,
269
+ * contextWindow?: number, vramRequiredMB?: number, sizeBytes?: number}>>}
270
+ */
271
+ async listAvailableModels() {
272
+ const registered = await this.#store.list();
273
+ const own = registered.map((r) => ({
274
+ modelId: r.model_id,
275
+ source: r.source ?? SOURCE.REMOTE,
276
+ model: r.model,
277
+ contextWindow: r.overrides?.context_window_size,
278
+ vramRequiredMB: r.vram_required_MB,
279
+ sizeBytes: r.sizeBytes,
280
+ }));
281
+ if (!this.#prebuilt) return own;
282
+
283
+ const { prebuiltAppConfig, functionCallingModelIds } = await this.#loadWebLLM();
284
+ const toolCalling = new Set(functionCallingModelIds ?? []);
285
+ const owned = new Set(own.map((m) => m.modelId));
286
+ const rest = prebuiltAppConfig.model_list
287
+ .filter((e) => !owned.has(e.model_id))
288
+ .map((e) => ({
289
+ modelId: e.model_id,
290
+ source: SOURCE.PREBUILT,
291
+ model: e.model,
292
+ contextWindow: e.overrides?.context_window_size,
293
+ vramRequiredMB: e.vram_required_MB,
294
+ // WebLLM ships the list; it is not derivable from the id.
295
+ toolCalling: toolCalling.has(e.model_id),
296
+ }));
297
+ return [...own, ...rest];
298
+ }
299
+
300
+ /**
301
+ * What this machine will admit to: WebGPU, adapter, `shader-f16`, the five
302
+ * limits that matter, storage quota. Cached — hardware does not change
303
+ * mid-session, and `requestAdapter()` is not free.
304
+ * @returns {Promise<import("./device.js").DeviceProbe>}
305
+ */
306
+ probe() {
307
+ this.#probe ??= probeDevice();
308
+ return this.#probe;
309
+ }
310
+
311
+ /**
312
+ * Whether a model will run here, before anything is downloaded.
313
+ * @param {string} modelId
314
+ * @returns {Promise<{ok: boolean, blockers: Array<object>, warnings: Array<object>}>}
315
+ */
316
+ async canRun(modelId) {
317
+ const [probe, available] = await Promise.all([this.probe(), this.listAvailableModels()]);
318
+ const found = available.find((m) => m.modelId === modelId);
319
+ if (!found) {
320
+ throw new EngineError(ERROR.UNKNOWN_MODEL, `Model "${modelId}" is not one this engine can load.`, {
321
+ modelId,
322
+ });
323
+ }
324
+ return canRun(
325
+ { model_id: found.modelId, vram_required_MB: found.vramRequiredMB, sizeBytes: found.sizeBytes },
326
+ probe,
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Which models this device should actually be asked to run, best first.
332
+ *
333
+ * The prebuilt list spans 239 MB to 31 GB; this is the answer to the first
334
+ * question a developer has and the one they have least basis to answer.
335
+ *
336
+ * @param {{maxVramMB?: number, needsVision?: boolean, needsToolCalling?: boolean,
337
+ * prefer?: "quality" | "speed"}} [opts]
338
+ */
339
+ async recommendModels({ needsToolCalling = false, ...opts } = {}) {
340
+ const [probe, appConfig] = await Promise.all([this.probe(), this.#appConfig()]);
341
+ let list = appConfig.model_list;
342
+ if (needsToolCalling) {
343
+ const { functionCallingModelIds } = await this.#loadWebLLM();
344
+ const ids = new Set(functionCallingModelIds ?? []);
345
+ list = list.filter((m) => ids.has(m.model_id));
346
+ }
347
+ return rankModels(list, { probe, ...opts });
348
+ }
349
+
350
+ /**
351
+ * Is this model's data on disk, so a load would need no network?
352
+ *
353
+ * Routes by who knows the keys. We wrote an injected model's artifacts and
354
+ * hold the manifest, so `verify()` answers exactly — including a `"partial"`
355
+ * verdict WebLLM cannot give. Everything else was fetched by WebLLM, which
356
+ * derives the keys as its loader did, so `hasModelInCache` is the answer.
357
+ *
358
+ * @returns {Promise<"cached" | "partial" | "absent">}
359
+ */
360
+ async cacheState(modelId) {
361
+ const record = await this.#store.get(modelId);
362
+ if (isInjected(record)) {
363
+ const { ok, missing } = await this.#store.verify(record);
364
+ if (ok) return "cached";
365
+ const total = Object.values(groupKeysByScope(record)).flat().length;
366
+ return missing.length >= total ? "absent" : "partial";
367
+ }
368
+ const { hasModelInCache } = await this.#loadWebLLM();
369
+ return (await hasModelInCache(modelId, await this.#appConfig())) ? "cached" : "absent";
370
+ }
371
+
372
+ /**
373
+ * Download a model into the cache **without building an engine**.
374
+ *
375
+ * For warming during onboarding: the bytes land while the user is still
376
+ * reading, and the later `load()` is a cache read. WebLLM cannot express this
377
+ * — `reload()` instantiates the wasm and needs a GPU before it fetches a
378
+ * single shard — so this is ours. See `prefetch.js` for the URL-derivation
379
+ * risk and the oracle that closes it.
380
+ *
381
+ * Needs no WebGPU at all, which is the other half of the point: an app can
382
+ * warm the cache on a machine it has not yet decided can run the model.
383
+ *
384
+ * @param {string} modelId
385
+ * @param {{signal?: AbortSignal, onProgress?: Function}} [opts]
386
+ */
387
+ async prefetch(modelId, { signal, onProgress } = {}) {
388
+ // Before anything, including the already-cached shortcut: a caller who
389
+ // aborted wants an abort, not a success they have to inspect to distrust.
390
+ if (signal?.aborted) {
391
+ throw new EngineError(ERROR.ABORTED, `Prefetch of "${modelId}" was aborted before it began.`, {
392
+ modelId,
393
+ });
394
+ }
395
+ const record = await this.#store.get(modelId);
396
+
397
+ // An injected model's bytes were written before it was ever registered;
398
+ // there is no URL to fetch from and nothing to do.
399
+ if (isInjected(record)) {
400
+ const { ok } = await this.#store.verify(record);
401
+ if (ok) return { modelId, files: 0, bytes: 0, alreadyCached: true };
402
+ throw new EngineError(
403
+ ERROR.CACHE_INCOMPLETE,
404
+ `"${modelId}" was injected from a folder, so it cannot be re-fetched. Re-register the folder.`,
405
+ { modelId },
406
+ );
407
+ }
408
+
409
+ const appConfig = await this.#appConfig();
410
+ const entry = appConfig.model_list.find((m) => m.model_id === modelId);
411
+ if (!entry) {
412
+ const near = nearMatches(modelId, appConfig.model_list.map((m) => m.model_id));
413
+ throw new EngineError(
414
+ ERROR.UNKNOWN_MODEL,
415
+ `Cannot prefetch "${modelId}": it is neither registered nor prebuilt. ` +
416
+ (near.length ? `Did you mean ${near.map((id) => `"${id}"`).join(", ")}? ` : ""),
417
+ { modelId, ...(near.length ? { near } : {}) },
418
+ );
419
+ }
420
+
421
+ const { hasModelInCache } = await this.#loadWebLLM();
422
+ if (await hasModelInCache(modelId, appConfig).catch(() => false)) {
423
+ return { modelId, files: 0, bytes: 0, alreadyCached: true };
424
+ }
425
+
426
+ const result = await prefetchModel({ modelId, record: entry, signal, onProgress });
427
+
428
+ // The oracle. `hasModelInCache` derives its keys through the very function
429
+ // `prefetch.js` mirrors, so this is the one check that can tell a correct
430
+ // prefetch from one that wrote a cache the loader will never read. Without
431
+ // it, a wrong key looks exactly like success and costs the user a second
432
+ // download of the whole model.
433
+ if (!(await hasModelInCache(modelId, appConfig).catch(() => false))) {
434
+ throw new EngineError(
435
+ ERROR.GENERATION_FAILED,
436
+ `Prefetch of "${modelId}" wrote ${result.files} artifacts, but WebLLM still reports the ` +
437
+ "model as uncached — the derived cache keys do not match the ones its loader looks for. " +
438
+ "Treat the cache as cold; load() will re-download. This is what a change to WebLLM's " +
439
+ "URL scheme looks like from here.",
440
+ { modelId, files: result.files },
441
+ );
442
+ }
443
+ return result;
444
+ }
445
+
446
+ /**
447
+ * Free a model's bytes and **keep the registry entry**, so it stays a model
448
+ * this engine knows how to get again — the distinction from
449
+ * `store.remove()`, which forgets the URL a remote model would need.
450
+ *
451
+ * Delegates for remote and prebuilt models: `deleteModelAllInfoInCache` is
452
+ * WebLLM's, covers tensors + wasm + config, and is maintained upstream.
453
+ */
454
+ async evict(modelId) {
455
+ if (this.#pools.has(modelId)) await this.unload(modelId);
456
+ return this.#evictBytes(modelId);
457
+ }
458
+
459
+ /**
460
+ * The byte-freeing half of `evict()`, with no pool handling.
461
+ *
462
+ * Split out so `unload(id, "cache")` can reach it without going back through
463
+ * `evict()` → `unload()`, which would re-enter this class for a pool that has
464
+ * just been torn down.
465
+ */
466
+ async #evictBytes(modelId) {
467
+ const record = await this.#store.get(modelId);
468
+ if (isInjected(record)) return this.#store.evictInjected(modelId);
469
+
470
+ const { deleteModelAllInfoInCache } = await this.#loadWebLLM();
471
+ await deleteModelAllInfoInCache(modelId, await this.#appConfig());
472
+ return { freedKeys: null };
473
+ }
474
+
475
+ /** The merged model list WebLLM's cache helpers key off. */
476
+ async #appConfig() {
477
+ const registered = await this.#store.list();
478
+ const prebuilt = this.#prebuilt ? (await this.#loadWebLLM()).prebuiltAppConfig : null;
479
+ return toAppConfig(registered, prebuilt);
480
+ }
481
+
482
+ /**
483
+ * Forget a model entirely: free its bytes **and** drop the registry entry.
484
+ *
485
+ * `evict()` first, because that is what knows how to reach the bytes for each
486
+ * source — and it has to happen before the record is deleted, since for a
487
+ * remote model the record holds the only URL those bytes can be derived from.
488
+ * Deleting the entry first would strand them in Cache Storage permanently.
489
+ */
490
+ async remove(modelId) {
491
+ const freed = await this.evict(modelId);
492
+ await this.#store.remove(modelId);
493
+ return freed;
494
+ }
495
+
496
+ /**
497
+ * Projected decode throughput for a model, in tokens per second.
498
+ *
499
+ * `basis: "measured"` once anything has actually decoded on this machine —
500
+ * the engine then knows its own achieved bandwidth and every projection is
501
+ * device-specific. Before that, `basis: "extrapolated"` from a reference
502
+ * machine, which is a starting point and says so.
503
+ *
504
+ * Decode is memory-bandwidth-bound, so this is close to the whole story:
505
+ * time per token scales with weight bytes and little else.
506
+ *
507
+ * @param {string} [modelId] defaults to the current model
508
+ */
509
+ async estimateSpeed(modelId = this.#current) {
510
+ let bytes = modelId ? this.#modelBytes.get(modelId) : 0;
511
+ if (!bytes && modelId) {
512
+ const found = (await this.listAvailableModels()).find((m) => m.modelId === modelId);
513
+ bytes = found?.sizeBytes ?? (found?.vramRequiredMB ?? 0) * 1024 * 1024;
514
+ }
515
+ return { modelId, ...projectSpeed(bytes, this.#decodeBytesPerSecond) };
516
+ }
517
+
518
+ /**
519
+ * What is actually switched on right now, as opposed to what the device could
520
+ * support.
521
+ *
522
+ * The distinction matters for KV reuse in particular: `probe().kvReuse` is a
523
+ * device capability, but the decision is taken inside the engine worker,
524
+ * which is the authority. A caller debugging "why is my second turn slow"
525
+ * needs the decision, not the capability.
526
+ */
527
+ async features() {
528
+ const probe = await this.probe();
529
+ const settings = await this.#store.getSettings();
530
+ const pool = this.#pool?.status();
531
+ // The configured K, which is not the same question as whether bursts run.
532
+ // `decodeSteps` stays the knob's value even when the fast path is off: it is
533
+ // what `configure()` would be changing, and reporting 1 here would make
534
+ // `environment()` advise `configure({ decodeSteps: 15 })` for a fault that
535
+ // setting cannot fix.
536
+ const decodeSteps = this.#state.decode?.steps ?? settings.decodeSteps;
537
+ // Non-empty means the live pipeline failed the contract and decoding was
538
+ // routed to stock single-step. The worker keeps posting the *configured* K
539
+ // either way, so `steps > 1` cannot answer this — asking it alone reported
540
+ // a disabled fast path as healthy, which is the one case this call exists for.
541
+ const multiStepOff = this.#state.decode?.multiStepOff;
542
+ return {
543
+ kvReuse: Boolean(probe.kvReuse),
544
+ shaderF16: Boolean(probe.features?.shaderF16),
545
+ decodeSteps,
546
+ multiStepDecoding: decodeSteps > 1 && !multiStepOff?.length,
547
+ /** The missing tvmjs internals, or null when the fast path is intact. */
548
+ multiStepOff: multiStepOff?.length ? multiStepOff : null,
549
+ engines: pool?.size ?? 0,
550
+ maxEngines: pool?.maxSize ?? settings.engineCount,
551
+ resident: this.resident,
552
+ // dispatches per flush: >1 means consecutive kernel launches are sharing a
553
+ // compute pass, i.e. the build-time batching patch is in effect. Observed,
554
+ // not asserted — a build with NO_PASS_MERGE=1 reports ~1.
555
+ computePassBatching: this.#state.decode?.flushes
556
+ ? this.#state.decode.dispatches / this.#state.decode.flushes
557
+ : null,
558
+ decode: this.#state.decode ?? null,
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Register a model. Two shapes, one call, and the difference is only where
564
+ * the bytes come from:
565
+ *
566
+ * ```js
567
+ * // fetched from a base URL you host — an HF repo, a CDN, your own origin
568
+ * await engine.registerModel({
569
+ * modelId: "my-model",
570
+ * model: "/models/my-model/",
571
+ * modelLib: "/models/my-model/my-model-webgpu.wasm",
572
+ * });
573
+ *
574
+ * // read off disk. No network connection at any point, ever.
575
+ * await engine.registerModel({ modelId: "my-model", files: entries });
576
+ * ```
577
+ *
578
+ * Both end up as one `model_list` entry that WebLLM's own loader resolves the
579
+ * same way — the local one only differs in that its base URL is minted on
580
+ * `.invalid` and its cache is populated before the loader ever looks.
581
+ *
582
+ * That origin is the *mechanism* of the offline guarantee, not a marker of
583
+ * it: `.invalid` is reserved by RFC 6761 and can never resolve, so there is
584
+ * no code path — no bug, no eviction, no future refactor — by which a local
585
+ * model reaches the network. It fails with a DNS error instead.
586
+ *
587
+ * `files` is `{ path, file }[]`; `filesFromDataTransfer` and
588
+ * `filesFromInput` build it from a drop event or a directory picker.
589
+ */
590
+ async registerModel(spec) {
591
+ if (spec?.files) {
592
+ if (spec.model || spec.modelLib) {
593
+ throw new EngineError(
594
+ ERROR.BAD_REQUEST,
595
+ "registerModel takes either `files` (local, never fetched) or `model`/`modelLib` (a base URL to fetch), not both.",
596
+ );
597
+ }
598
+ return ingestModelFolder(spec.files, {
599
+ store: this.#store,
600
+ modelId: spec.modelId,
601
+ modelType: spec.modelType,
602
+ onProgress: spec.onProgress,
603
+ });
604
+ }
605
+ return this.#store.registerModel(spec);
606
+ }
607
+
608
+ // ---------------------------------------------------------------- engine ---
609
+
610
+ #setState(patch) {
611
+ Object.assign(this.#state, patch);
612
+ const snapshot = this.state;
613
+ for (const listener of [...this.#listeners]) {
614
+ try {
615
+ listener(snapshot);
616
+ } catch {
617
+ /* a subscriber that throws must not stall the engine */
618
+ }
619
+ }
620
+ }
621
+
622
+ #assertWebGPU() {
623
+ if (!this.hasWebGPU) {
624
+ throw new EngineError(
625
+ ERROR.NO_WEBGPU,
626
+ "WebGPU is unavailable in this context. On macOS Firefox, set dom.webgpu.enabled=true " +
627
+ "(and gfx.webgpu.ignore-blocklist=true if your GPU is blocklisted) in about:config, then restart Firefox.",
628
+ );
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Bring a model up, whatever form you have it in.
634
+ *
635
+ * One entry point for all three routes, because from a caller's side "load a
636
+ * model" is one intention and having to know which of `load`,
637
+ * `registerModel` and `ingestModelFolder` to reach for is a decision the
638
+ * library can make for them:
639
+ *
640
+ * ```js
641
+ * load("Llama-3.2-1B-Instruct-q4f16_1-MLC") // prebuilt or registered id
642
+ * load("https://huggingface.co/mlc-ai/Foo", { modelLib }) // a URL you host
643
+ * load({ model, modelLib }) // the same, explicit
644
+ * load({ files }) | load(fileList) | load(dataTransfer) // a folder, no network
645
+ * ```
646
+ *
647
+ * `registerModel` and `ingestModelFolder` remain, unchanged, as the low-level
648
+ * primitives — this composes them rather than replacing them.
649
+ *
650
+ * **A URL always needs `modelLib`.** It is not guessed; see `sources.js` for
651
+ * the measurement behind that. **`defer: true`** registers the source and
652
+ * stops there, returning the record instead of the state — the manager's
653
+ * drop-now-load-later flow.
654
+ *
655
+ * Additive residency: a model already resident stays resident, so switching
656
+ * back to it costs nothing. That is only safe while the weights fit, so
657
+ * `keepResident: false` (the default) unloads whatever else is up first —
658
+ * the old single-model behaviour, and the safe one on a 16 GB machine.
659
+ * Pass `keepResident: true` to hold both, having checked the budget yourself
660
+ * with `canRun()`.
661
+ *
662
+ * @param {string | object} src an id, a URL, `{model, modelLib}`, or a folder
663
+ * @param {{keepResident?: boolean, signal?: AbortSignal, defer?: boolean,
664
+ * id?: string, modelLib?: string, modelType?: string, contextWindow?: number,
665
+ * vramRequiredMB?: number, onProgress?: Function}} [opts]
666
+ * @returns {Promise<object>} the engine state, or the registry record when `defer`
667
+ */
668
+ async load(src, opts = {}) {
669
+ const source = classifySource(src, opts);
670
+
671
+ if (source.kind === SOURCE_KIND.ID) {
672
+ if (opts.defer) {
673
+ throw new EngineError(
674
+ ERROR.BAD_REQUEST,
675
+ `\`defer\` registers a source without loading it, but "${source.modelId}" is an id — ` +
676
+ "there is nothing to register. Drop `defer`, or pass a URL or a folder.",
677
+ { modelId: source.modelId },
678
+ );
679
+ }
680
+ return this.#loadById(source.modelId, opts);
681
+ }
682
+
683
+ const record = await this.#register(source, opts);
684
+ if (opts.defer) return record;
685
+ return this.#loadById(record.model_id, opts);
686
+ }
687
+
688
+ /** Turns a classified non-id source into a registry record. */
689
+ async #register(source, opts) {
690
+ if (source.kind === SOURCE_KIND.FILES) {
691
+ return ingestModelFolder(await toEntries(source.files), {
692
+ store: this.#store,
693
+ modelId: source.modelId,
694
+ modelType: opts.modelType,
695
+ onProgress: opts.onProgress,
696
+ });
697
+ }
698
+ return this.#store.registerModel({
699
+ modelId: source.modelId,
700
+ model: source.model,
701
+ modelLib: source.modelLib,
702
+ modelType: opts.modelType,
703
+ contextWindow: opts.contextWindow,
704
+ vramRequiredMB: opts.vramRequiredMB,
705
+ });
706
+ }
707
+
708
+ /**
709
+ * Bring a registered or prebuilt id up and make it current.
710
+ *
711
+ * Cancellation is WebLLM's, not ours: `unload()` aborts the `reloadController`
712
+ * whose signal it threads through every artifact fetch. And resume is free —
713
+ * WebLLM caches each artifact as it arrives and skips what is already present,
714
+ * so a re-`load()` picks up where the abort left off. Call `evict()` to
715
+ * discard a partial download instead.
716
+ *
717
+ * @param {string} modelId
718
+ * @param {{keepResident?: boolean, signal?: AbortSignal}} [opts]
719
+ */
720
+ async #loadById(modelId, { keepResident = false, signal } = {}) {
721
+ if (signal?.aborted) {
722
+ throw new EngineError(ERROR.ABORTED, `Load of "${modelId}" was aborted before it began.`, {
723
+ modelId,
724
+ });
725
+ }
726
+ if (this.#pools.has(modelId)) return this.use(modelId);
727
+
728
+ const inFlight = this.#loading.get(modelId);
729
+ if (inFlight) {
730
+ await inFlight.catch(() => {});
731
+ return this.#pools.has(modelId) ? this.use(modelId) : this.state;
732
+ }
733
+
734
+ const loading = (async () => {
735
+ this.#assertWebGPU();
736
+
737
+ const models = await this.#store.list();
738
+ const registered = models.find((m) => m.model_id === modelId);
739
+
740
+ // Only an injected model can be *unrecoverably* broken by eviction, and
741
+ // checking costs nothing, so it gates the load — before the WebLLM bundle
742
+ // is fetched, so the common offline failure stays fast.
743
+ if (isInjected(registered)) {
744
+ const { ok, missing } = await this.#store.verify(registered);
745
+ if (!ok) {
746
+ throw new EngineError(
747
+ ERROR.CACHE_INCOMPLETE,
748
+ `Cache for "${modelId}" is incomplete (${missing.length} artifact(s) evicted, e.g. ${missing[0].split("/").pop()}). Re-register the model folder.`,
749
+ { modelId, missing },
750
+ );
751
+ }
752
+ }
753
+
754
+ if (!registered && !this.#prebuilt) {
755
+ const near = nearMatches(modelId, models.map((m) => m.model_id));
756
+ throw new EngineError(
757
+ ERROR.UNKNOWN_MODEL,
758
+ `Model "${modelId}" is not registered, and prebuilt models are disabled. ` +
759
+ (near.length ? `Did you mean ${near.map((id) => `"${id}"`).join(", ")}? ` : "") +
760
+ `Call load(url, { modelLib }) or load({ files }) first.`,
761
+ { modelId, prebuilt: false, ...(near.length ? { near } : {}) },
762
+ );
763
+ }
764
+
765
+ const { engineCount, decodeSteps } = await this.#store.getSettings();
766
+ this.#setState({
767
+ status: ENGINE_STATE.LOADING,
768
+ modelId,
769
+ error: null,
770
+ progress: { text: "Starting", progress: 0 },
771
+ });
772
+
773
+ // Each resident model is a full copy of its weights. Nothing reports free
774
+ // VRAM to a page (AI.md, "The pool grows, it is not sized"), so the
775
+ // default is to make room rather than to gamble on it fitting.
776
+ if (!keepResident) {
777
+ for (const id of [...this.#pools.keys()]) await this.#unloadOne(id);
778
+ }
779
+
780
+ const { CreateWebWorkerMLCEngine, prebuiltAppConfig } = await this.#loadWebLLM();
781
+ const appConfig = toAppConfig(models, this.#prebuilt ? prebuiltAppConfig : null);
782
+
783
+ if (!appConfig.model_list.some((m) => m.model_id === modelId)) {
784
+ // A typo'd id is the single most likely way to arrive here, and the
785
+ // fix is almost always visible in the list we are already holding.
786
+ const near = nearMatches(modelId, appConfig.model_list.map((m) => m.model_id));
787
+ throw new EngineError(
788
+ ERROR.UNKNOWN_MODEL,
789
+ `Model "${modelId}" is neither registered nor in WebLLM's prebuilt list. ` +
790
+ (near.length
791
+ ? `Did you mean ${near.map((id) => `"${id}"`).join(", ")}? `
792
+ : "") +
793
+ `Use listAvailableModels() to see what this engine can load.`,
794
+ { modelId, ...(near.length ? { near } : {}) },
795
+ );
796
+ }
797
+
798
+ const pool = new EnginePool({
799
+ size: engineCount,
800
+ createEngine: async (_index, onProgress) => {
801
+ const worker = new Worker(this.#workerUrl, { type: "module" });
802
+ // Listener, not `onmessage`: WebLLM claims `onmessage` for its own RPC.
803
+ worker.addEventListener("message", (event) => {
804
+ if (event.data?.ewgpuStats) this.#setState({ decode: event.data.ewgpuStats });
805
+ });
806
+ // A worker whose script 404s does not throw from `new Worker()` — it
807
+ // fires one `error` event and is then simply silent, so WebLLM's
808
+ // handshake below never resolves and the load hangs until the caller
809
+ // gives up. That is the exact shape of the Vite dep-optimizer bug this
810
+ // names: esbuild copies `new URL("./engine-worker.js",
811
+ // import.meta.url)` into `.vite/deps/` verbatim, where the sibling
812
+ // file does not exist. Racing the handshake against this turns a hang
813
+ // into a sentence.
814
+ const workerFailed = new Promise((_, reject) => {
815
+ worker.addEventListener("error", (event) => {
816
+ reject(
817
+ new EngineError(
818
+ ERROR.PACKAGE_INCOMPLETE,
819
+ `The decode worker failed to load from ${this.#workerUrl}. ` +
820
+ "If you are on Vite, its dependency pre-bundler rewrote the worker URL to a " +
821
+ "path that does not exist — add `optimizeDeps: { exclude: [\"everything-webgpu\"] }` " +
822
+ "to vite.config.js, or pass `workerUrl` yourself.",
823
+ { cause: "worker-unreachable", workerUrl: String(this.#workerUrl), underlying: event.message },
824
+ ),
825
+ );
826
+ });
827
+ });
828
+ // Sent before WebLLM's own handshake so the first token already decodes
829
+ // multi-step; worker message order guarantees it arrives first.
830
+ worker.postMessage({ kind: WORKER_CONFIGURE, decodeSteps });
831
+ const engine = await Promise.race([
832
+ CreateWebWorkerMLCEngine(worker, modelId, {
833
+ appConfig,
834
+ initProgressCallback: onProgress,
835
+ }),
836
+ workerFailed,
837
+ ]);
838
+ // The worker owns the decode loop, so runtime knobs go straight to it
839
+ // rather than through WebLLM's request path.
840
+ engine.configure = (patch) => worker.postMessage({ kind: WORKER_CONFIGURE, ...patch });
841
+ // Tear the realm down with the engine, not just the model.
842
+ const unloadEngine = engine.unload.bind(engine);
843
+ engine.unload = async () => {
844
+ await unloadEngine().catch(() => {});
845
+ worker.terminate();
846
+ };
847
+ return engine;
848
+ },
849
+ onStateChange: () => {
850
+ if (this.#current === modelId) this.#syncState();
851
+ },
852
+ });
853
+
854
+ // Reachable from here on, so an abort has something to tear down. The
855
+ // listener bumps the pool's generation, which is what stops an engine
856
+ // that finishes building *after* the abort from installing itself.
857
+ this.#loadingPools.set(modelId, pool);
858
+ if (signal) signal.addEventListener("abort", () => void this.#unloadOne(modelId), { once: true });
859
+
860
+ const abortedNow = async () => {
861
+ await this.#unloadOne(modelId);
862
+ throw new EngineError(ERROR.ABORTED, `Load of "${modelId}" was aborted.`, { modelId });
863
+ };
864
+
865
+ // Checked on both sides of the load. Before: an abort that landed while
866
+ // the registry was being read must not start a download at all. After: an
867
+ // abort during the download has already torn the pool down, and this is
868
+ // what turns that into a rejection rather than a silent no-op.
869
+ if (signal?.aborted) return abortedNow();
870
+ await pool.load((progress) => this.#setState({ progress }));
871
+ if (signal?.aborted) return abortedNow();
872
+
873
+ const entry = appConfig.model_list.find((m) => m.model_id === modelId);
874
+ this.#modelBytes.set(
875
+ modelId,
876
+ registered?.sizeBytes ?? (entry?.vram_required_MB ?? 0) * 1024 * 1024,
877
+ );
878
+ this.#pools.set(modelId, pool);
879
+ this.#current = modelId;
880
+ this.#setState({ status: ENGINE_STATE.READY, progress: null, error: null });
881
+ this.#syncState();
882
+ return this.state;
883
+ })();
884
+
885
+ this.#loading.set(modelId, loading);
886
+ try {
887
+ return await loading;
888
+ } catch (err) {
889
+ const engineError = asEngineError(err);
890
+ this.#setState({
891
+ status: this.#pools.size ? ENGINE_STATE.READY : ENGINE_STATE.ERROR,
892
+ progress: null,
893
+ error: engineError.message,
894
+ errorCode: engineError.code,
895
+ });
896
+ // A failed load must not leave `modelId` pointing at the model that did
897
+ // not come up — it was set optimistically when LOADING began.
898
+ this.#syncState();
899
+ throw engineError;
900
+ } finally {
901
+ this.#loading.delete(modelId);
902
+ this.#loadingPools.delete(modelId);
903
+ }
904
+ }
905
+
906
+ /**
907
+ * Let a model go, at one of two depths.
908
+ *
909
+ * ```js
910
+ * unload() // the current model's VRAM; cached bytes stay
911
+ * unload(id) // that model's VRAM
912
+ * unload(id, "cache") // and delete its cached bytes, keeping the registry entry
913
+ * ```
914
+ *
915
+ * At `"vram"` the bytes stay on disk, so loading it again costs no network —
916
+ * that is what makes switching back cheap, and the difference between this
917
+ * and `remove()`.
918
+ *
919
+ * **A bare `unload()` frees only the current model**, not every resident one.
920
+ * `unloadAll()` is the explicit form for that: freeing everything is the more
921
+ * destructive of the two readings and should have to be asked for by name.
922
+ *
923
+ * @param {string} [modelId] defaults to the current model. Omit both this and
924
+ * any resident model to no-op.
925
+ * @param {"vram"|"cache"} [level]
926
+ */
927
+ async unload(modelId = this.#current, level = UNLOAD_LEVEL.VRAM) {
928
+ if (!Object.values(UNLOAD_LEVEL).includes(level)) {
929
+ throw new EngineError(
930
+ ERROR.BAD_REQUEST,
931
+ `unload() level must be ${Object.values(UNLOAD_LEVEL).map((l) => `"${l}"`).join(" or ")}, ` +
932
+ `not "${level}". To forget the model entirely, use remove().`,
933
+ { level },
934
+ );
935
+ }
936
+ if (modelId) {
937
+ await this.#unloadOne(modelId);
938
+ if (level === UNLOAD_LEVEL.CACHE) await this.#evictBytes(modelId);
939
+ }
940
+ this.#setState({
941
+ status: this.#pools.size ? ENGINE_STATE.READY : ENGINE_STATE.IDLE,
942
+ progress: null,
943
+ error: null,
944
+ });
945
+ this.#syncState();
946
+ return this.state;
947
+ }
948
+
949
+ /** Unload every resident model. */
950
+ async unloadAll() {
951
+ for (const id of [...this.#pools.keys()]) await this.#unloadOne(id);
952
+ this.#setState({ status: ENGINE_STATE.IDLE, progress: null, error: null });
953
+ this.#syncState();
954
+ return this.state;
955
+ }
956
+
957
+ async #unloadOne(modelId) {
958
+ const pool = this.#pools.get(modelId) ?? this.#loadingPools.get(modelId);
959
+ if (!pool) return;
960
+ this.#pools.delete(modelId);
961
+ this.#loadingPools.delete(modelId);
962
+ if (this.#current === modelId) this.#current = this.#pools.keys().next().value ?? null;
963
+ await pool.unload();
964
+ }
965
+
966
+ /**
967
+ * Learn this machine's achieved decode bandwidth from a finished generation.
968
+ *
969
+ * WebLLM already measures throughput and puts it on every response as
970
+ * `usage.extra.decode_tokens_per_s` — the pool asks for usage and was
971
+ * discarding it. Tokens per second times weight bytes is bytes per second,
972
+ * which is the one number a projection for *any other* model needs.
973
+ *
974
+ * The worker's own decode probe is not this and does not replace it: it
975
+ * splits a burst into CPU-encode and GPU-sync, which is what the multi-step
976
+ * and compute-pass work is measured against. Nothing upstream reports that.
977
+ */
978
+ #calibrate(usage, modelId) {
979
+ const tps = usage?.extra?.decode_tokens_per_s;
980
+ const bytes = this.#modelBytes.get(modelId);
981
+ if (!tps || !bytes) return;
982
+ this.#decodeBytesPerSecond = tps * bytes;
983
+ }
984
+
985
+ /** Recomputes the parts of `state` that are views onto the current pool. */
986
+ #syncState() {
987
+ this.#setState({
988
+ modelId: this.#current,
989
+ resident: this.resident,
990
+ pool: this.#pool?.status() ?? { size: 0, busy: 0, queued: 0 },
991
+ });
992
+ }
993
+
994
+ /** Loads on demand so callers can just ask for a completion. */
995
+ async #ensurePool(modelId) {
996
+ // A request naming a resident model goes straight to it, without disturbing
997
+ // which model is current — routing is not switching.
998
+ if (modelId && this.#pools.has(modelId)) return this.#pools.get(modelId);
999
+ if (modelId) await this.load(modelId);
1000
+ if (!this.#pool) {
1001
+ const fallback = modelId ?? this.#current ?? (await this.#store.list())[0]?.model_id;
1002
+ if (!fallback) {
1003
+ throw new EngineError(
1004
+ ERROR.NO_MODEL,
1005
+ "No model is registered yet. Call registerModel(), or load() a prebuilt id.",
1006
+ );
1007
+ }
1008
+ await this.load(fallback);
1009
+ }
1010
+ return modelId ? (this.#pools.get(modelId) ?? this.#pool) : this.#pool;
1011
+ }
1012
+
1013
+ async #buildParams(payload) {
1014
+ const settings = await this.#store.getSettings();
1015
+ const messages = Array.isArray(payload.messages) ? [...payload.messages] : [];
1016
+ if (messages.length === 0) {
1017
+ throw new EngineError(ERROR.BAD_REQUEST, "`messages` must be a non-empty array.");
1018
+ }
1019
+ if (settings.systemPrompt && !messages.some((m) => m.role === "system")) {
1020
+ messages.unshift({ role: "system", content: settings.systemPrompt });
1021
+ }
1022
+ return {
1023
+ messages,
1024
+ temperature: payload.temperature ?? settings.temperature,
1025
+ max_tokens: payload.max_tokens ?? settings.maxTokens,
1026
+ ...(payload.response_format ? { response_format: payload.response_format } : {}),
1027
+ ...(payload.extra_body ? { extra_body: payload.extra_body } : {}),
1028
+ };
1029
+ }
1030
+
1031
+ /**
1032
+ * One completion.
1033
+ *
1034
+ * Named `complete` rather than `chat` so `engine.chat.completions.create()`
1035
+ * — the WebLLM-shaped facade, Phase 2 — can take that name without a rename.
1036
+ *
1037
+ * @param {CompletionRequest} payload
1038
+ * @param {(delta: string) => void} [onChunk] called per streamed text delta
1039
+ * @returns {Promise<CompletionResult>}
1040
+ */
1041
+ async complete(payload, onChunk) {
1042
+ // Adapts the raw chunk stream to the documented string callback. The guard
1043
+ // matters: raw chunks include a role-only first chunk and a finish-only
1044
+ // last chunk, which callers of this signature have never seen.
1045
+ return this.completeRaw(
1046
+ payload,
1047
+ onChunk &&
1048
+ ((chunk) => {
1049
+ const delta = chunk?.choices?.[0]?.delta?.content;
1050
+ if (delta) onChunk(delta);
1051
+ }),
1052
+ );
1053
+ }
1054
+
1055
+ /**
1056
+ * `complete()`, but the callback receives WebLLM's chunk verbatim.
1057
+ *
1058
+ * Exists so the `chat.completions.create()` facade can pass chunks straight
1059
+ * through instead of rebuilding an envelope — which is what dropped
1060
+ * `tool_calls`, flattened `logprobs` and restamped `created`.
1061
+ *
1062
+ * @param {CompletionRequest} payload
1063
+ * @param {(chunk: object) => void} [onRawChunk]
1064
+ * @returns {Promise<CompletionResult & {toolCalls?: Array<object>}>}
1065
+ */
1066
+ async completeRaw(payload, onRawChunk) {
1067
+ const pool = await this.#ensurePool(payload.modelId);
1068
+ const result = unwrap(
1069
+ await pool.submit({
1070
+ ...scheduling(payload),
1071
+ id: payload.id,
1072
+ params: await this.#buildParams(payload),
1073
+ onChunk: onRawChunk,
1074
+ }),
1075
+ );
1076
+ this.#calibrate(result.usage, this.#current);
1077
+ return {
1078
+ text: result.text,
1079
+ usage: result.usage,
1080
+ finishReason: result.finishReason,
1081
+ ...(result.toolCalls ? { toolCalls: result.toolCalls } : {}),
1082
+ ...(result.cancelled ? { cancelled: true } : {}),
1083
+ ...(result.preempted ? { preempted: true } : {}),
1084
+ };
1085
+ }
1086
+
1087
+ // ------------------------------------------- the three shapes, as verbs ---
1088
+ //
1089
+ // `complete()` expresses all three. These exist because the scheduling is the
1090
+ // part that is easy to get wrong and invisible when you do — see recipes.js.
1091
+
1092
+ /**
1093
+ * One question, one answer, nothing kept.
1094
+ *
1095
+ * ```js
1096
+ * const answer = await engine.ask("Summarise this in one line:\n" + doc);
1097
+ * ```
1098
+ *
1099
+ * @param {string | Array<object>} input
1100
+ * @param {object} [opts] anything `complete()` takes, plus `onDelta` to stream
1101
+ * @returns {Promise<string>}
1102
+ */
1103
+ ask(input, opts) {
1104
+ return ask(this, input, opts);
1105
+ }
1106
+
1107
+ /**
1108
+ * A multi-turn conversation that keeps its own history.
1109
+ *
1110
+ * ```js
1111
+ * const chat = engine.conversation({ system: "You are terse." });
1112
+ * await chat.say("hello");
1113
+ * await chat.say("and again?"); // remembers
1114
+ * ```
1115
+ *
1116
+ * @param {object} [opts] `system`, `keep`, plus `complete()` defaults
1117
+ */
1118
+ conversation(opts) {
1119
+ return conversation(this, opts);
1120
+ }
1121
+
1122
+ /**
1123
+ * Ghost text, with the debounce/supersede/drop-if-stale discipline built in
1124
+ * and the prompt left to you.
1125
+ *
1126
+ * ```js
1127
+ * const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` });
1128
+ * editor.on("input", async () => {
1129
+ * const hint = await ghost.suggest(editor.textBefore());
1130
+ * if (hint !== null) render(hint); // null means a newer keystroke won
1131
+ * });
1132
+ * editor.on("blur", () => ghost.cancel());
1133
+ * ```
1134
+ *
1135
+ * @param {object} opts must include `prompt`
1136
+ */
1137
+ ghostText(opts) {
1138
+ return ghostText(this, opts);
1139
+ }
1140
+
1141
+ /**
1142
+ * Embed text into vectors, through the same scheduler as everything else.
1143
+ *
1144
+ * ```js
1145
+ * const [vector] = await engine.embed("a sentence", { modelId: EMBED_MODEL });
1146
+ * const vectors = await engine.embed(["one", "two"], { modelId: EMBED_MODEL });
1147
+ * ```
1148
+ *
1149
+ * **Needs an embedding model**, not a chat model — `snowflake-arctic-embed-*`
1150
+ * in WebLLM's prebuilt list, from 239 MB. They are separate models, so this
1151
+ * usually names `modelId` explicitly and holds it resident alongside a chat
1152
+ * model with `load(id, { keepResident: true })`.
1153
+ *
1154
+ * Returns bare vectors because that is what a caller does arithmetic on; the
1155
+ * OpenAI envelope is available as `embedRaw()` for anyone porting code that
1156
+ * expects `data[].embedding`.
1157
+ *
1158
+ * **A running embedding cannot be interrupted.** Cancellation and preemption
1159
+ * work by making a decode loop break out; one forward pass has no loop, so a
1160
+ * `cancel()` that lands after the job starts marks it cancelled but does not
1161
+ * stop it. Queued embeddings supersede and cancel normally. This is tolerable
1162
+ * because an embedding is milliseconds where a completion is seconds — but it
1163
+ * is a weaker guarantee than `complete()` gives, so it is stated rather than
1164
+ * discovered.
1165
+ *
1166
+ * @param {string | string[]} input
1167
+ * @param {{modelId?: string, task?: string, session?: string,
1168
+ * priority?: string, preemptible?: boolean, id?: string}} [opts]
1169
+ * @returns {Promise<number[][]>} one vector per input, in order
1170
+ */
1171
+ async embed(input, opts = {}) {
1172
+ const { data } = await this.embedRaw(input, opts);
1173
+ return data.map((d) => d.embedding);
1174
+ }
1175
+
1176
+ /** `embed()`, returning WebLLM's OpenAI-shaped envelope untouched. */
1177
+ async embedRaw(input, opts = {}) {
1178
+ const texts = Array.isArray(input) ? input : [input];
1179
+ if (texts.length === 0 || texts.some((t) => typeof t !== "string")) {
1180
+ throw new EngineError(
1181
+ ERROR.BAD_REQUEST,
1182
+ "embed() takes a string or a non-empty array of strings.",
1183
+ { received: Array.isArray(input) ? `array of ${input.length}` : typeof input },
1184
+ );
1185
+ }
1186
+
1187
+ const pool = await this.#ensurePool(opts.modelId);
1188
+ const result = unwrap(
1189
+ await pool.submit({
1190
+ ...scheduling(opts),
1191
+ id: opts.id,
1192
+ kind: JOB_KIND.EMBEDDING,
1193
+ params: { input: texts },
1194
+ }),
1195
+ );
1196
+ return { data: result.embeddings ?? [], usage: result.usage };
1197
+ }
1198
+
1199
+ /**
1200
+ * Independent prompts, fanned across the pool. This is the only way to beat
1201
+ * the ~10 tok/s single-stream ceiling, so anything embarrassingly parallel
1202
+ * (translating a page, labelling a list) should arrive here rather than as a
1203
+ * loop of `complete` calls.
1204
+ *
1205
+ * @param {CompletionRequest & {requests: Array<Partial<CompletionRequest>>}} payload
1206
+ * @param {(item: BatchItem) => void} [onItem] called as each item lands
1207
+ * @returns {Promise<Array<BatchItem>>}
1208
+ */
1209
+ async batch(payload, onItem = () => {}) {
1210
+ const requests = payload.requests;
1211
+ if (!Array.isArray(requests) || requests.length === 0) {
1212
+ throw new EngineError(ERROR.BAD_REQUEST, "`requests` must be a non-empty array.");
1213
+ }
1214
+ const pool = await this.#ensurePool(payload.modelId);
1215
+ const sched = scheduling(payload);
1216
+ // One batch is one task, however many requests it is: "translate this page"
1217
+ // should hold one engine, not every engine. The pool reserves its last free
1218
+ // slot for a different task, so ghost-text never queues behind the page.
1219
+ const task = payload.task ?? `batch-${payload.id ?? crypto.randomUUID()}`;
1220
+
1221
+ return Promise.all(
1222
+ requests.map(async (req, index) => {
1223
+ const merged = { ...payload, ...req, requests: undefined };
1224
+ const result = await pool.submit({
1225
+ ...sched,
1226
+ ...scheduling(merged),
1227
+ session: req.session, // a batch shares no session unless an item names one
1228
+ // An item that names its own session is its own task again.
1229
+ task: req.task ?? req.session ?? task,
1230
+ params: await this.#buildParams(merged),
1231
+ });
1232
+ const item = {
1233
+ index,
1234
+ engineIndex: result.engineIndex,
1235
+ startedAt: result.startedAt,
1236
+ finishedAt: result.finishedAt,
1237
+ ...(result.error
1238
+ ? { error: result.error }
1239
+ : {
1240
+ text: result.text,
1241
+ usage: result.usage,
1242
+ finishReason: result.finishReason,
1243
+ ...(result.toolCalls ? { toolCalls: result.toolCalls } : {}),
1244
+ ...(result.cancelled ? { cancelled: true } : {}),
1245
+ }),
1246
+ };
1247
+ onItem(item);
1248
+ return item;
1249
+ }),
1250
+ );
1251
+ }
1252
+
1253
+ /**
1254
+ * Cancels by job id or by session key.
1255
+ * @param {string} idOrSession
1256
+ * @returns {number} how many jobs it stopped
1257
+ */
1258
+ cancel(idOrSession) {
1259
+ let stopped = 0;
1260
+ for (const pool of this.#pools.values()) stopped += pool.cancel(idOrSession);
1261
+ return stopped;
1262
+ }
1263
+
1264
+ /**
1265
+ * Applies a runtime knob to the running pool and persists it as the default.
1266
+ *
1267
+ * `decodeSteps` is the multi-step decode width (AI.md, "Multi-step decoding").
1268
+ * It takes effect on the next burst — no reload — which is what makes sweeping
1269
+ * it to find this machine's tick boundary cheap.
1270
+ */
1271
+ async configure(patch) {
1272
+ const applied = {};
1273
+ if (patch.decodeSteps !== undefined) applied.decodeSteps = clampSteps(patch.decodeSteps);
1274
+ if (patch.engineCount !== undefined) {
1275
+ const n = Math.round(Number(patch.engineCount));
1276
+ if (!Number.isFinite(n) || n < 1) {
1277
+ throw new EngineError(
1278
+ ERROR.BAD_REQUEST,
1279
+ `engineCount must be a positive integer, not ${JSON.stringify(patch.engineCount)}.`,
1280
+ { engineCount: patch.engineCount },
1281
+ );
1282
+ }
1283
+ applied.engineCount = n;
1284
+ }
1285
+ if (Object.keys(applied).length === 0) {
1286
+ // Naming the knobs matters: this is the error a caller hits after
1287
+ // `environment()` told them something was operable, so it has to agree
1288
+ // with that report about what the operable things are.
1289
+ throw new EngineError(
1290
+ ERROR.BAD_REQUEST,
1291
+ "`configure` needs at least one setting. Operable: `decodeSteps`, `engineCount`.",
1292
+ { operable: ["decodeSteps", "engineCount"] },
1293
+ );
1294
+ }
1295
+ await this.#store.setSettings(applied);
1296
+ // Only `decodeSteps` is hot. `engineCount` is persisted and read when a pool
1297
+ // is built, so a live pool keeps the size it came up with — `environment()`
1298
+ // reports that gap rather than pretending the change took effect.
1299
+ let engines = 0;
1300
+ if (applied.decodeSteps !== undefined) {
1301
+ for (const pool of this.#pools.values()) engines += pool.configure({ decodeSteps: applied.decodeSteps });
1302
+ }
1303
+ return { settings: applied, engines };
1304
+ }
1305
+ }
1306
+
1307
+ /**
1308
+ * Normalise every folder shape a caller might hold into `{path, file}[]`.
1309
+ *
1310
+ * A drop event gives a `DataTransfer`, `<input webkitdirectory>` gives a
1311
+ * `FileList`, and a caller who has already unpacked one gives the entries. All
1312
+ * three mean "this folder", so `load()` accepts all three rather than making
1313
+ * the caller find the right converter first.
1314
+ */
1315
+ async function toEntries(files) {
1316
+ if (isDataTransfer(files)) return filesFromDataTransfer(files);
1317
+ if (isFileList(files)) return filesFromInput(files);
1318
+ return files;
1319
+ }
1320
+
1321
+ /** Scheduling metadata is per-request; the pool, not the caller, acts on it. */
1322
+ function scheduling(payload) {
1323
+ return {
1324
+ task: payload.task,
1325
+ session: payload.session,
1326
+ priority: payload.priority ?? PRIORITY.NORMAL,
1327
+ preemptible: payload.preemptible,
1328
+ };
1329
+ }
1330
+
1331
+ function unwrap(result) {
1332
+ // The pool resolves rather than rejects, so a failed generation arrives as a
1333
+ // string on the result. It has no code of its own by then.
1334
+ if (result.error) throw asEngineError(result.error, ERROR.GENERATION_FAILED);
1335
+ return result;
1336
+ }
src/engine/environment.js ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `environment()` — one call that says what this machine will do, and why.
3
+ *
4
+ * It absorbs the *read* half of `probe()`, `features()` and `estimateSpeed()`.
5
+ * It is **read-only**. Writes go through `configure()`, which already existed
6
+ * and stays the one way to change a setting.
7
+ *
8
+ * That split was a deliberate correction to the original plan, which had one
9
+ * function do both and told them apart by argument shape. Implicit dispatch is
10
+ * the opposite of foolproof: `environment({ scope: "device", decodeSteps: 8 })`
11
+ * has no intuitive answer, and the reason it has none is that one function was
12
+ * being asked to do two jobs. So reading and writing are different verbs, and
13
+ * the report's `fix` field names the write to make instead of performing it.
14
+ *
15
+ * Every line carries the same five fields, because a caller should be able to
16
+ * render the whole report without special-casing any of it:
17
+ *
18
+ * severity blocked | degraded | tune | info | ok
19
+ * affects what the reader loses — in their terms, not the engine's
20
+ * cause the measured fact behind the verdict
21
+ * fix the exact call or instruction, or `null` when nothing can be done
22
+ * operable whether this is reachable from JS at all
23
+ *
24
+ * `fix` is `null` for anything the caller genuinely cannot change — hardware,
25
+ * build-time flags, browser settings that JS cannot reach. Reporting a
26
+ * consequence with no remedy is still worth doing: "your second turn is slow
27
+ * because this device caps storage buffers at 9" is the difference between a
28
+ * bug report and an informed decision.
29
+ *
30
+ * **`canRun(modelId)` stays where it is.** It is per-*model*; this is
31
+ * per-*device*, and model ranking belongs with model discovery. The two answer
32
+ * different questions and merging them would make both worse.
33
+ */
34
+ import { ERROR, EngineError } from "./errors.js";
35
+
36
+ export const SEVERITY = {
37
+ /** Nothing will run until this is dealt with. */
38
+ BLOCKED: "blocked",
39
+ /** It runs, measurably worse. */
40
+ DEGRADED: "degraded",
41
+ /** It runs well; this is a dial worth turning. */
42
+ TUNE: "tune",
43
+ /** Worth knowing, nothing to do. */
44
+ INFO: "info",
45
+ /** Checked, and fine. */
46
+ OK: "ok",
47
+ };
48
+
49
+ /** Worst-first, so `ok` can be computed and lines sorted by urgency. */
50
+ const RANK = [SEVERITY.BLOCKED, SEVERITY.DEGRADED, SEVERITY.TUNE, SEVERITY.INFO, SEVERITY.OK];
51
+
52
+ /** Paged prefill's binding count; below this, cross-turn KV reuse cannot build. */
53
+ const PAGED_PREFILL_STORAGE_BUFFERS = 10;
54
+
55
+ const SCOPES = new Set(["full", "local", "device"]);
56
+
57
+ /**
58
+ * Builds the callable `engine.environment` — a function with `.measure()` on it.
59
+ *
60
+ * A function rather than an object because the common case is asking for the
61
+ * whole report, and `environment()` should be the short spelling of that.
62
+ *
63
+ * @param {import("./engine.js").ScheduledEngine} engine
64
+ */
65
+ export function environmentFacade(engine) {
66
+ const environment = (opts = {}) => report(engine, opts);
67
+
68
+ /**
69
+ * One calibration generation, then the measured rate.
70
+ *
71
+ * Returns tok/s for the **current model** — the only one it can measure,
72
+ * since measuring means generating. The device-level bytes/sec figure it
73
+ * teaches the engine is what projects *other* models, and that projection is
74
+ * `estimateSpeed(id)`'s job, not this one's.
75
+ */
76
+ environment.measure = async ({ tokens = 32 } = {}) => {
77
+ const modelId = engine.state.modelId;
78
+ if (!modelId || engine.resident.length === 0) {
79
+ throw new EngineError(
80
+ ERROR.NO_MODEL,
81
+ "environment.measure() generates in order to measure, so it needs a resident model. " +
82
+ "Call load() first, or use estimateSpeed(id) for a projection.",
83
+ );
84
+ }
85
+ await engine.complete({
86
+ messages: [{ role: "user", content: "Count from one to twenty." }],
87
+ max_tokens: tokens,
88
+ // Calibration must not jump a queue a real caller is waiting in.
89
+ priority: "background",
90
+ preemptible: true,
91
+ });
92
+ return { ...(await engine.estimateSpeed(modelId)), tokens };
93
+ };
94
+
95
+ return environment;
96
+ }
97
+
98
+ async function report(engine, { scope = "full", ...rest } = {}) {
99
+ // The one thing an implicit read/write API could not do: notice that a caller
100
+ // meant to write. `configure()` is named in the error because that is the
101
+ // call they wanted.
102
+ const stray = Object.keys(rest);
103
+ if (stray.length) {
104
+ throw new EngineError(
105
+ ERROR.BAD_REQUEST,
106
+ `environment() reports; it does not change anything. ` +
107
+ `To set ${stray.map((k) => `\`${k}\``).join(", ")}, call configure({ ${stray.join(", ")} }).`,
108
+ { keys: stray },
109
+ );
110
+ }
111
+ if (!SCOPES.has(scope)) {
112
+ throw new EngineError(
113
+ ERROR.BAD_REQUEST,
114
+ `environment() scope must be ${[...SCOPES].map((s) => `"${s}"`).join(", ")}, not "${scope}".`,
115
+ { scope },
116
+ );
117
+ }
118
+
119
+ const device = await engine.probe();
120
+ const lines = [...deviceLines(device)];
121
+
122
+ // A blocked device short-circuits everything below it. "K=15 forward steps
123
+ // per GPU sync" is true and completely useless next to "no model can load" —
124
+ // and burying the one line that matters under four that do not is exactly the
125
+ // failure this report exists to prevent.
126
+ const blocked = lines.some((l) => l.severity === SEVERITY.BLOCKED);
127
+
128
+ if (scope !== "device" && !blocked) {
129
+ const features = await engine.features();
130
+ const settings = await engine.store.getSettings();
131
+ lines.push(...runtimeLines(engine, device, features, settings));
132
+ }
133
+
134
+ // `full` is the only scope that may touch the network: `estimateSpeed` reads
135
+ // the model list, which pulls the ~6 MB WebLLM bundle when prebuilt models
136
+ // are on. `local` exists precisely so a caller can ask cheaply and often.
137
+ let speed = null;
138
+ if (scope === "full" && !blocked && engine.state.modelId) {
139
+ speed = await engine.estimateSpeed().catch(() => null);
140
+ }
141
+
142
+ lines.sort((a, b) => RANK.indexOf(a.severity) - RANK.indexOf(b.severity));
143
+ const worst = lines.reduce(
144
+ (acc, l) => (RANK.indexOf(l.severity) < RANK.indexOf(acc) ? l.severity : acc),
145
+ SEVERITY.OK,
146
+ );
147
+
148
+ return {
149
+ scope,
150
+ ok: worst !== SEVERITY.BLOCKED,
151
+ severity: worst,
152
+ device,
153
+ ...(speed ? { speed } : {}),
154
+ lines,
155
+ };
156
+ }
157
+
158
+ const line = (id, severity, affects, cause, fix = null) => ({
159
+ id,
160
+ severity,
161
+ affects,
162
+ cause,
163
+ fix,
164
+ operable: fix !== null,
165
+ });
166
+
167
+ function* deviceLines(device) {
168
+ if (!device.webgpu) {
169
+ // `reason` already carries the exact per-browser instruction, and it is not
170
+ // reachable from JS — so it is the fix text, not an operable one.
171
+ yield {
172
+ ...line("webgpu", SEVERITY.BLOCKED, "everything — no model can load", device.reason ?? "navigator.gpu is absent"),
173
+ fix: device.reason ?? null,
174
+ operable: false,
175
+ };
176
+ return;
177
+ }
178
+ yield line("webgpu", SEVERITY.OK, null, "navigator.gpu is present and an adapter was granted");
179
+
180
+ const buffers = device.limits?.maxStorageBuffersPerShaderStage;
181
+ if (device.kvReuse === false) {
182
+ yield line(
183
+ "kvReuse",
184
+ SEVERITY.DEGRADED,
185
+ "every turn after the first re-prefills the whole history, so a long conversation waits seconds for its first token",
186
+ `paged prefill binds ${PAGED_PREFILL_STORAGE_BUFFERS} storage buffers per stage; this device allows ${buffers ?? "fewer"}`,
187
+ // Genuinely not operable: it is a driver/browser limit, not a setting.
188
+ null,
189
+ );
190
+ } else if (device.kvReuse) {
191
+ yield line("kvReuse", SEVERITY.OK, null, `storage buffers per stage: ${buffers}`);
192
+ }
193
+
194
+ yield device.features?.shaderF16
195
+ ? line("shaderF16", SEVERITY.OK, null, "shader-f16 is supported, so q4f16 models run at full speed")
196
+ : line(
197
+ "shaderF16",
198
+ SEVERITY.DEGRADED,
199
+ "q4f16 models fall back to f32 maths, roughly halving decode",
200
+ "the adapter does not expose shader-f16",
201
+ null,
202
+ );
203
+
204
+ const { quota, usage, persisted } = device.storage ?? {};
205
+ if (persisted === false) {
206
+ yield line(
207
+ "persist",
208
+ SEVERITY.TUNE,
209
+ "the browser may evict a multi-GB model under storage pressure, forcing a re-download",
210
+ "storage is not marked persistent",
211
+ "await ensurePersistent() — from everything-webgpu/adapters/idb. One-way, and may prompt.",
212
+ );
213
+ } else if (persisted) {
214
+ yield line("persist", SEVERITY.OK, null, "storage is persistent; models will not be evicted");
215
+ }
216
+
217
+ if (quota && usage !== undefined) {
218
+ const freeGB = (quota - usage) / 1e9;
219
+ yield freeGB < 2
220
+ ? line(
221
+ "quota",
222
+ SEVERITY.DEGRADED,
223
+ "a model may fail to cache, or evict one already there",
224
+ `${freeGB.toFixed(1)} GB free of ${(quota / 1e9).toFixed(1)} GB`,
225
+ "Free space, or remove(id) a model you no longer need.",
226
+ )
227
+ : line("quota", SEVERITY.INFO, null, `${freeGB.toFixed(1)} GB free of ${(quota / 1e9).toFixed(1)} GB`);
228
+ }
229
+ }
230
+
231
+ function* runtimeLines(engine, device, features, settings) {
232
+ // The 2d guard's report. Nothing else surfaces it, and its whole point is
233
+ // that the failure is otherwise silent.
234
+ const off = engine.state.decode?.multiStepOff;
235
+ if (off?.length) {
236
+ yield line(
237
+ "multiStepDecoding",
238
+ SEVERITY.DEGRADED,
239
+ "decode fell back to one GPU sync per token — roughly half throughput",
240
+ `the live pipeline is missing ${off.length} tvmjs internal(s): ${off.slice(0, 3).join("; ")}`,
241
+ "This is what a WebLLM upgrade looks like. Run `npm test` (webllm-contract) to see whether the names are gone from the bundle too.",
242
+ );
243
+ } else if (features.multiStepDecoding) {
244
+ yield line("multiStepDecoding", SEVERITY.OK, null, `K=${features.decodeSteps} forward steps per GPU sync`);
245
+ }
246
+
247
+ yield features.decodeSteps === 1
248
+ ? line(
249
+ "decodeSteps",
250
+ SEVERITY.TUNE,
251
+ "decode pays one GPU sync per token, which is the ~10 tok/s ceiling",
252
+ "decodeSteps is 1, so multi-step decoding is off",
253
+ "configure({ decodeSteps: 15 })",
254
+ )
255
+ : line(
256
+ "decodeSteps",
257
+ SEVERITY.INFO,
258
+ null,
259
+ `K=${features.decodeSteps}; the best value falls as the model grows, since it is ms/step that fills the 100 ms tick`,
260
+ "configure({ decodeSteps: n })",
261
+ );
262
+
263
+ yield line(
264
+ "engineCount",
265
+ SEVERITY.INFO,
266
+ null,
267
+ `pool cap ${features.maxEngines}, ${features.engines} built; it grows only when a second task waits`,
268
+ "configure({ engineCount: n }) — persisted, and applies to pools built after it",
269
+ );
270
+
271
+ // Observed, not asserted: a build with NO_PASS_MERGE=1 reports ~1.
272
+ const batching = features.computePassBatching;
273
+ if (batching !== null) {
274
+ yield batching > 1.5
275
+ ? line("computePassBatching", SEVERITY.OK, null, `${batching.toFixed(1)} kernel launches per flush`)
276
+ : line(
277
+ "computePassBatching",
278
+ SEVERITY.DEGRADED,
279
+ "one compute pass per kernel launch, which measured ~2.5x slower",
280
+ `${batching.toFixed(1)} launches per flush — the build-time patch is not in effect`,
281
+ // Build-time, not runtime: nothing a caller holding the engine can do.
282
+ null,
283
+ );
284
+ }
285
+
286
+ if (settings.engineCount && features.maxEngines && settings.engineCount !== features.maxEngines) {
287
+ yield line(
288
+ "engineCountPending",
289
+ SEVERITY.INFO,
290
+ null,
291
+ `engineCount is ${settings.engineCount} but the live pool was built with ${features.maxEngines}`,
292
+ "Reload the model to apply it.",
293
+ );
294
+ }
295
+ }
src/engine/errors.js ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Typed failures.
3
+ *
4
+ * Every error the engine raised used to be a bare string, so a caller wanting
5
+ * to tell "this device has no WebGPU" from "that folder was missing a shard"
6
+ * had to match on prose — which then silently broke whenever the prose
7
+ * improved. A code is the part of an error message that is allowed to be
8
+ * depended on.
9
+ *
10
+ * The codes are deliberately few. Each one exists because a caller does
11
+ * something *different* about it, not because it names a different line of
12
+ * code:
13
+ *
14
+ * NO_WEBGPU tell the user to check flags/hardware; retrying is futile
15
+ * NO_MODEL nothing registered at all — send them to your setup flow
16
+ * UNKNOWN_MODEL that id is not resolvable; `listAvailableModels()` says what is
17
+ * CACHE_INCOMPLETE a locally-registered model was evicted; re-register the folder
18
+ * INVALID_MODEL_FOLDER the folder is not a compiled MLC model; `detail` says what is missing
19
+ * BAD_REQUEST the caller's arguments are wrong; a bug in the caller
20
+ * ABORTED the caller cancelled it; not a failure, and not to be
21
+ * reported to a user as one
22
+ * GENERATION_FAILED the model failed mid-generation
23
+ * PACKAGE_INCOMPLETE this package is not wired into your build correctly —
24
+ * a missing `vendor/` bundle, or a decode worker the
25
+ * bundler did not emit. Fixed once, in the build, by
26
+ * the developer; never branched on at runtime.
27
+ *
28
+ * `PACKAGE_INCOMPLETE` covers two causes with one code on purpose. They are
29
+ * different sentences and `detail.cause` tells them apart, but no caller writes
30
+ * a different `catch` branch for them: both mean "your build is wrong, the app
31
+ * has not shipped yet", and both are fixed by editing config, not by handling
32
+ * an error path. A second code would have grown the table a caller reads
33
+ * without giving that caller anything new to do.
34
+ *
35
+ * `message` stays human-readable and stays the thing you print. `detail`
36
+ * carries whatever structured context the site had — the missing cache keys,
37
+ * the absent field — so a caller never has to parse the sentence.
38
+ */
39
+ export const ERROR = {
40
+ NO_WEBGPU: "NO_WEBGPU",
41
+ NO_MODEL: "NO_MODEL",
42
+ UNKNOWN_MODEL: "UNKNOWN_MODEL",
43
+ CACHE_INCOMPLETE: "CACHE_INCOMPLETE",
44
+ INVALID_MODEL_FOLDER: "INVALID_MODEL_FOLDER",
45
+ BAD_REQUEST: "BAD_REQUEST",
46
+ ABORTED: "ABORTED",
47
+ GENERATION_FAILED: "GENERATION_FAILED",
48
+ PACKAGE_INCOMPLETE: "PACKAGE_INCOMPLETE",
49
+ };
50
+
51
+ export class EngineError extends Error {
52
+ /**
53
+ * @param {string} code one of ERROR
54
+ * @param {string} message human-readable, safe to show a user
55
+ * @param {object} [detail] structured context, so callers need not parse `message`
56
+ */
57
+ constructor(code, message, detail) {
58
+ super(message);
59
+ this.name = "EngineError";
60
+ this.code = code;
61
+ if (detail !== undefined) this.detail = detail;
62
+ }
63
+
64
+ /** Wire form. `error` stays a plain string so existing callers keep working. */
65
+ toJSON() {
66
+ return { code: this.code, message: this.message, ...(this.detail ? { detail: this.detail } : {}) };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Normalise anything thrown into an EngineError.
72
+ *
73
+ * Errors from WebLLM, the GPU stack and the structured-clone boundary arrive as
74
+ * plain Errors, DOMExceptions or strings, and a caller should not have to care
75
+ * which. Anything unrecognised becomes `GENERATION_FAILED` rather than being
76
+ * given a more specific code it has not earned.
77
+ */
78
+ export function asEngineError(err, fallback = ERROR.GENERATION_FAILED) {
79
+ if (err instanceof EngineError) return err;
80
+ const message = String(err?.message ?? err);
81
+ return new EngineError(fallback, message);
82
+ }
83
+
84
+ export const isEngineError = (err, code) =>
85
+ err instanceof EngineError && (code === undefined || err.code === code);
src/engine/index.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Public entry point.
3
+ *
4
+ * Nothing reachable from here touches `browser.*`. A page, a worker, an
5
+ * extension background page and a Node test all import the same modules; only
6
+ * the StorageAdapter differs (`src/adapters/`).
7
+ *
8
+ * Migrating off `@mlc-ai/web-llm` is one line — the call below is unchanged:
9
+ *
10
+ * ```js
11
+ * import { CreateScheduledEngine } from "everything-webgpu";
12
+ *
13
+ * const engine = await CreateScheduledEngine("Llama-3.2-1B-Instruct-q4f16_1-MLC");
14
+ * const reply = await engine.chat.completions.create({
15
+ * messages: [{ role: "user", content: "hi" }],
16
+ * session: "ghost-text", // added by this engine
17
+ * priority: "interactive", // added by this engine
18
+ * });
19
+ * ```
20
+ *
21
+ * The longer form, when you want to choose the store or the model source:
22
+ *
23
+ * ```js
24
+ * import { ScheduledEngine, ModelStore, ingestModelFolder } from "everything-webgpu";
25
+ * import { indexedDBStorage } from "everything-webgpu/adapters/idb";
26
+ *
27
+ * const engine = new ScheduledEngine({ store: new ModelStore(await indexedDBStorage()) });
28
+ *
29
+ * // Weights arrive by any of three routes. One call covers the latter two.
30
+ * await engine.load("Llama-3.2-1B-Instruct-q4f16_1-MLC"); // prebuilt, from HuggingFace
31
+ *
32
+ * await engine.registerModel({ // a base URL you host
33
+ * modelId: "my-model",
34
+ * model: "/models/my-model/",
35
+ * modelLib: "/models/my-model/my-model-webgpu.wasm",
36
+ * });
37
+ * await engine.registerModel({ modelId: "my-model", files }); // off disk, never any network
38
+ *
39
+ * const { text } = await engine.complete({ messages: [{ role: "user", content: "hi" }] });
40
+ * ```
41
+ *
42
+ * `listAvailableModels()` enumerates all three. Pass `{ prebuilt: false }` to
43
+ * the engine for a build that must never fetch a model over the network.
44
+ *
45
+ * The scheduling fields — `task`, `session`, `priority`, `preemptible` — are
46
+ * what this adds over calling WebLLM directly. See AI.md, "The three shapes of
47
+ * work".
48
+ */
49
+ export { ScheduledEngine } from "./engine.js";
50
+ export { CreateScheduledEngine } from "./create.js";
51
+ export {
52
+ canRun,
53
+ probeDevice,
54
+ projectSpeed,
55
+ rankModels,
56
+ REFERENCE_DECODE_BYTES_PER_SECOND,
57
+ } from "./device.js";
58
+ export { ERROR, EngineError, asEngineError, isEngineError } from "./errors.js";
59
+ export { EnginePool } from "./pool.js";
60
+
61
+ export {
62
+ ModelStore,
63
+ MODEL_TYPE,
64
+ SOURCE,
65
+ isInjected,
66
+ DEFAULT_SETTINGS,
67
+ CACHE_CONFIG,
68
+ CACHE_MODEL,
69
+ CACHE_WASM,
70
+ baseUrlFor,
71
+ groupKeysByScope,
72
+ toAppConfig,
73
+ formatBytes,
74
+ } from "./model-store.js";
75
+
76
+ export { filesFromDataTransfer, filesFromInput, ingestModelFolder } from "./ingest.js";
77
+ export { prefetchModel, resolveModelUrl } from "./prefetch.js";
78
+ export { ask, conversation, ghostText } from "./recipes.js";
79
+
80
+ export {
81
+ DEFAULT_DECODE_STEPS,
82
+ MAX_DECODE_STEPS,
83
+ burstSize,
84
+ clampSteps,
85
+ installMultiStepDecoding,
86
+ } from "./multistep.js";
87
+
88
+ export { ENGINE_STATE, PRIORITY, PRIORITY_ORDER, UNLOAD_LEVEL, WORKER_CONFIGURE } from "./constants.js";
89
+ export { SEVERITY } from "./environment.js";
src/engine/ingest.js ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Drag-and-drop ingestion: turn a folder of MLC-compiled model artifacts into
3
+ * populated Cache Storage entries, with no network involved at any point.
4
+ *
5
+ * Everything is validated before the first byte is written, so a folder that is
6
+ * missing a shard fails immediately instead of after copying 2 GB.
7
+ */
8
+ import { ERROR, EngineError } from "./errors.js";
9
+ import { CACHE_CONFIG, CACHE_MODEL, CACHE_WASM, SOURCE, baseUrlFor, toModelType } from "./model-store.js";
10
+
11
+ /** WebLLM asks for this name; older MLC exports ship `ndarray-cache.json`. */
12
+ export const TENSOR_MANIFEST = "tensor-cache.json";
13
+ export const LEGACY_TENSOR_MANIFEST = "ndarray-cache.json";
14
+ export const CHAT_CONFIG = "mlc-chat-config.json";
15
+
16
+ export const CONTENT_TYPES = {
17
+ json: "application/json",
18
+ wasm: "application/wasm",
19
+ bin: "application/octet-stream",
20
+ };
21
+
22
+ /**
23
+ * Walk a DataTransfer from a drop event into flat `{ path, file }` entries.
24
+ * Uses the entries API so dropping a *folder* works, not just a file selection.
25
+ */
26
+ export async function filesFromDataTransfer(dataTransfer) {
27
+ // `Array.from` throughout, for the reason `filesFromInput` gives: a
28
+ // DataTransferItemList and a FileList are array-like, and only sometimes
29
+ // iterable. Spreading them threw from inside here, three frames from the drop
30
+ // handler the caller actually wrote.
31
+ const roots = Array.from(dataTransfer.items)
32
+ .filter((item) => item.kind === "file")
33
+ .map((item) => (item.webkitGetAsEntry ? item.webkitGetAsEntry() : null));
34
+
35
+ if (roots.some((entry) => entry === null)) {
36
+ // No entries API: fall back to the flat file list (a folder drop yields nothing).
37
+ return Array.from(dataTransfer.files, (file) => ({
38
+ path: file.webkitRelativePath || file.name,
39
+ file,
40
+ }));
41
+ }
42
+
43
+ const out = [];
44
+ await Promise.all(roots.filter(Boolean).map((entry) => walkEntry(entry, "", out)));
45
+ return out;
46
+ }
47
+
48
+ async function walkEntry(entry, prefix, out) {
49
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
50
+ if (entry.isFile) {
51
+ out.push({ path, file: await new Promise((res, rej) => entry.file(res, rej)) });
52
+ return;
53
+ }
54
+ const reader = entry.createReader();
55
+ // readEntries() returns at most ~100 entries per call; drain it.
56
+ for (;;) {
57
+ const batch = await new Promise((res, rej) => reader.readEntries(res, rej));
58
+ if (batch.length === 0) break;
59
+ await Promise.all(batch.map((child) => walkEntry(child, path, out)));
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Turns `<input webkitdirectory>` output into the same `{ path, file }` shape.
65
+ *
66
+ * `Array.from`, not spread: a real `FileList` is iterable, but plenty of things
67
+ * that behave like one are only array-like, and spreading those fails with
68
+ * "fileList is not iterable" — an error that names none of the three places it
69
+ * could have come from. Array.from accepts both.
70
+ */
71
+ export function filesFromInput(fileList) {
72
+ return Array.from(fileList, (file) => ({
73
+ path: file.webkitRelativePath || file.name,
74
+ file,
75
+ }));
76
+ }
77
+
78
+ /**
79
+ * @param {Array<{path: string, file: File}>} entries
80
+ * @param {object} opts
81
+ * @param {import("./model-store.js").ModelStore} opts.store where the registry entry lands
82
+ * @param {string} [opts.modelId] overrides the id inferred from the folder name
83
+ * @param {"llm"|"embedding"|"vlm"} [opts.modelType] declare a vision model, or WebLLM
84
+ * rejects every image sent to it
85
+ * @param {(p: {phase: string, done: number, total: number, label: string}) => void} [opts.onProgress]
86
+ * @returns {Promise<object>} the saved registry record
87
+ */
88
+ export async function ingestModelFolder(entries, { store, modelId, modelType, onProgress = () => {} } = {}) {
89
+ if (!store) {
90
+ throw new EngineError(ERROR.BAD_REQUEST, "ingestModelFolder needs a `store` to save the registry entry into.");
91
+ }
92
+ if (!entries?.length) {
93
+ throw new EngineError(ERROR.INVALID_MODEL_FOLDER, "Nothing was dropped — expected a model folder.", {
94
+ reason: "empty",
95
+ });
96
+ }
97
+
98
+ const byPath = new Map();
99
+ const byName = new Map();
100
+ for (const { path, file } of entries) {
101
+ const relative = stripRoot(path);
102
+ byPath.set(relative, file);
103
+ // Last writer wins; `find()` prefers the exact relative path anyway.
104
+ byName.set(basename(relative), file);
105
+ }
106
+
107
+ const find = (name) => byPath.get(name) ?? byName.get(basename(name));
108
+
109
+ onProgress({ phase: "validating", done: 0, total: 1, label: "Reading manifests" });
110
+
111
+ const configFile = find(CHAT_CONFIG);
112
+ if (!configFile) {
113
+ throw new EngineError(
114
+ ERROR.INVALID_MODEL_FOLDER,
115
+ `Missing ${CHAT_CONFIG}. Use the folder produced by \`mlc_llm convert_weights\` + \`gen_config\`, not a raw HuggingFace checkpoint.`,
116
+ { reason: "missing-config", missing: [CHAT_CONFIG] },
117
+ );
118
+ }
119
+ const chatConfig = await readJson(configFile, CHAT_CONFIG);
120
+
121
+ const tensorFile = find(TENSOR_MANIFEST) ?? find(LEGACY_TENSOR_MANIFEST);
122
+ if (!tensorFile) {
123
+ throw new EngineError(
124
+ ERROR.INVALID_MODEL_FOLDER,
125
+ `Missing ${TENSOR_MANIFEST} (or legacy ${LEGACY_TENSOR_MANIFEST}) — the weight shard index.`,
126
+ { reason: "missing-manifest", missing: [TENSOR_MANIFEST] },
127
+ );
128
+ }
129
+ const isLegacyManifest = !find(TENSOR_MANIFEST);
130
+ const tensorManifest = await readJson(tensorFile, tensorFile.name);
131
+
132
+ const records = tensorManifest.records;
133
+ if (!Array.isArray(records) || records.length === 0) {
134
+ throw new EngineError(
135
+ ERROR.INVALID_MODEL_FOLDER,
136
+ `${tensorFile.name} has no "records" array — it is not an MLC weight index.`,
137
+ { reason: "malformed-manifest" },
138
+ );
139
+ }
140
+
141
+ const shardPaths = records.map((r) => r.dataPath).filter(Boolean);
142
+ if (shardPaths.length !== records.length) {
143
+ throw new EngineError(
144
+ ERROR.INVALID_MODEL_FOLDER,
145
+ `${tensorFile.name} has records without a "dataPath".`,
146
+ { reason: "malformed-manifest" },
147
+ );
148
+ }
149
+ const missingShards = shardPaths.filter((p) => !find(p));
150
+ if (missingShards.length) {
151
+ throw new EngineError(
152
+ ERROR.INVALID_MODEL_FOLDER,
153
+ `${missingShards.length} weight shard(s) missing from the folder: ${missingShards.slice(0, 5).join(", ")}${missingShards.length > 5 ? ", …" : ""}`,
154
+ { reason: "missing-shards", missing: missingShards },
155
+ );
156
+ }
157
+
158
+ const tokenizerNames = Array.isArray(chatConfig.tokenizer_files) ? chatConfig.tokenizer_files : [];
159
+ const tokenizerName = ["tokenizer.json", "tokenizer.model"].find(
160
+ (name) => tokenizerNames.includes(name) && find(name),
161
+ );
162
+ if (!tokenizerName) {
163
+ throw new EngineError(
164
+ ERROR.INVALID_MODEL_FOLDER,
165
+ `No usable tokenizer. ${CHAT_CONFIG} lists [${tokenizerNames.join(", ") || "nothing"}], and neither tokenizer.json nor tokenizer.model is present in the folder.`,
166
+ { reason: "missing-tokenizer", expected: tokenizerNames },
167
+ );
168
+ }
169
+
170
+ const wasmEntries = [...byPath.entries()].filter(([p]) => p.endsWith(".wasm"));
171
+ if (wasmEntries.length === 0) {
172
+ throw new EngineError(
173
+ ERROR.INVALID_MODEL_FOLDER,
174
+ "No .wasm model library found. Add the matching `*-webgpu.wasm` from mlc-ai/binary-mlc-llm-libs to the folder.",
175
+ { reason: "missing-wasm" },
176
+ );
177
+ }
178
+ if (wasmEntries.length > 1) {
179
+ throw new EngineError(
180
+ ERROR.INVALID_MODEL_FOLDER,
181
+ `Found ${wasmEntries.length} .wasm files (${wasmEntries.map(([p]) => p).join(", ")}); the folder must contain exactly one model library.`,
182
+ { reason: "ambiguous-wasm", found: wasmEntries.map(([p]) => p) },
183
+ );
184
+ }
185
+ const [wasmPath, wasmFile] = wasmEntries[0];
186
+ const wasmName = basename(wasmPath);
187
+
188
+ const id = (modelId || inferModelId(entries) || wasmName.replace(/(-webgpu)?\.wasm$/, "")).trim();
189
+ if (!id) {
190
+ throw new EngineError(
191
+ ERROR.INVALID_MODEL_FOLDER,
192
+ "Could not determine a model id — name the folder after the model, or pass `modelId`.",
193
+ { reason: "no-model-id" },
194
+ );
195
+ }
196
+
197
+ const base = baseUrlFor(id);
198
+
199
+ // Everything validated: build the write plan.
200
+ const plan = [
201
+ { scope: CACHE_CONFIG, url: base + CHAT_CONFIG, file: configFile, type: CONTENT_TYPES.json },
202
+ { scope: CACHE_MODEL, url: base + TENSOR_MANIFEST, file: tensorFile, type: CONTENT_TYPES.json },
203
+ {
204
+ scope: CACHE_MODEL,
205
+ url: base + tokenizerName,
206
+ file: find(tokenizerName),
207
+ type: tokenizerName.endsWith(".json") ? CONTENT_TYPES.json : CONTENT_TYPES.bin,
208
+ },
209
+ { scope: CACHE_WASM, url: base + wasmName, file: wasmFile, type: CONTENT_TYPES.wasm },
210
+ ...shardPaths.map((p) => ({
211
+ scope: CACHE_MODEL,
212
+ url: new URL(p, base).href,
213
+ file: find(p),
214
+ type: CONTENT_TYPES.bin,
215
+ })),
216
+ ];
217
+ if (isLegacyManifest) {
218
+ // Keep the original name addressable too, so a future WebLLM that reverts
219
+ // to `ndarray-cache.json` still hits cache.
220
+ plan.push({
221
+ scope: CACHE_MODEL,
222
+ url: base + LEGACY_TENSOR_MANIFEST,
223
+ file: tensorFile,
224
+ type: CONTENT_TYPES.json,
225
+ });
226
+ }
227
+
228
+ const openCaches = new Map();
229
+ let done = 0;
230
+ for (const item of plan) {
231
+ if (!openCaches.has(item.scope)) openCaches.set(item.scope, await caches.open(item.scope));
232
+ onProgress({ phase: "writing", done, total: plan.length, label: basename(item.url) });
233
+ await openCaches.get(item.scope).put(
234
+ new Request(item.url),
235
+ new Response(item.file, { status: 200, headers: { "Content-Type": item.type } }),
236
+ );
237
+ done += 1;
238
+ }
239
+ onProgress({ phase: "writing", done, total: plan.length, label: "done" });
240
+
241
+ const keys = { [CACHE_CONFIG]: [], [CACHE_MODEL]: [], [CACHE_WASM]: [] };
242
+ for (const item of plan) keys[item.scope].push(item.url);
243
+
244
+ return store.save({
245
+ model_id: id,
246
+ model: base,
247
+ model_lib: base + wasmName,
248
+ source: SOURCE.INJECTED,
249
+ ...(toModelType(modelType) !== undefined ? { model_type: toModelType(modelType) } : {}),
250
+ ...(chatConfig.context_window_size > 0
251
+ ? { overrides: { context_window_size: chatConfig.context_window_size } }
252
+ : {}),
253
+ keys,
254
+ sizeBytes: plan.reduce((sum, item) => sum + item.file.size, 0),
255
+ fileCount: plan.length,
256
+ shardCount: shardPaths.length,
257
+ tokenizer: tokenizerName,
258
+ wasm: wasmName,
259
+ addedAt: new Date().toISOString(),
260
+ });
261
+ }
262
+
263
+ async function readJson(file, label) {
264
+ try {
265
+ return JSON.parse(await file.text());
266
+ } catch (err) {
267
+ throw new EngineError(ERROR.INVALID_MODEL_FOLDER, `${label} is not valid JSON: ${err.message}`, {
268
+ reason: "malformed-json",
269
+ file: label,
270
+ });
271
+ }
272
+ }
273
+
274
+ /** Drops the dropped-folder name so `Qwen3-4B/tokenizer.json` keys as `tokenizer.json`. */
275
+ function stripRoot(path) {
276
+ const parts = path.split("/");
277
+ return parts.length > 1 ? parts.slice(1).join("/") : path;
278
+ }
279
+
280
+ function basename(path) {
281
+ return path.split("/").pop();
282
+ }
283
+
284
+ /**
285
+ * A dropped *folder* gives every entry the same first path segment; a flat
286
+ * multi-file selection gives each entry a bare filename. Only the former names
287
+ * the model.
288
+ */
289
+ function inferModelId(entries) {
290
+ const roots = new Set(
291
+ entries.filter((e) => e.path.includes("/")).map((e) => e.path.split("/")[0]),
292
+ );
293
+ return roots.size === 1 ? [...roots][0] : "";
294
+ }
src/engine/model-store.js ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The model registry, and the Cache Storage layout WebLLM expects.
3
+ *
4
+ * A model reaches the engine by one of three routes. They differ only in where
5
+ * the weights are fetched from; everything downstream is identical, because all
6
+ * three end up as one `model_list` entry WebLLM's own loader resolves.
7
+ *
8
+ * prebuilt one of the 163 entries in WebLLM's `prebuiltAppConfig`, on
9
+ * HuggingFace. Nothing to register: `load("Llama-3.2-1B-Instruct-
10
+ * q4f16_1-MLC")` just works.
11
+ * remote `registerModel({ model, modelLib })` with any base URL — an HF
12
+ * repo, your own CDN, a path on your own origin, localhost. This
13
+ * is how a developer points the engine at weights they host.
14
+ * injected `ingestModelFolder()` writes a local folder straight into Cache
15
+ * Storage. No network at any point, for offline or private builds.
16
+ *
17
+ * The injected route mints a synthetic https base URL and pre-populates the
18
+ * exact cache scopes/keys WebLLM's loader would have populated from the
19
+ * network, so `reload()` finds every artifact already cached and issues zero
20
+ * requests. That origin is `.invalid` on purpose: it can never resolve, so an
21
+ * injected model whose cache was evicted fails loudly instead of quietly
22
+ * pulling a gigabyte off the network. Enabling downloads for the other two
23
+ * routes cannot weaken that guarantee.
24
+ *
25
+ * Registry and settings hang off a `ModelStore` holding an injected
26
+ * `StorageAdapter`. That is the whole reason this file is no longer
27
+ * extension-bound: `browser.storage.local` was the only WebExtension API in the
28
+ * engine core outside the router.
29
+ *
30
+ * Cache Storage is deliberately *not* injected. `caches` exists in every secure
31
+ * context, and the cache keys are the contract with WebLLM's loader — putting
32
+ * an abstraction over them would hide the one thing that has to stay exact.
33
+ *
34
+ * Cache scopes (must stay in sync with @mlc-ai/web-llm):
35
+ * webllm/config -> <base>mlc-chat-config.json
36
+ * webllm/model -> <base>tensor-cache.json, tokenizer file, every shard
37
+ * webllm/wasm -> <base><lib>.wasm
38
+ */
39
+ import { ERROR, EngineError } from "./errors.js";
40
+
41
+ export const CACHE_CONFIG = "webllm/config";
42
+ export const CACHE_MODEL = "webllm/model";
43
+ export const CACHE_WASM = "webllm/wasm";
44
+
45
+ /**
46
+ * `.invalid` is reserved by RFC 6761 and can never resolve, so a bug that skips
47
+ * the cache surfaces as a DNS failure instead of a silent download.
48
+ * The `/resolve/main/` suffix makes WebLLM's `cleanModelUrl()` a no-op.
49
+ */
50
+ const VIRTUAL_ORIGIN = "https://local-model.invalid";
51
+
52
+ const STORAGE_KEY = "models";
53
+ const SETTINGS_KEY = "settings";
54
+
55
+ export const DEFAULT_SETTINGS = {
56
+ /** Empty list = every installed extension may call the API. Wire adapter only. */
57
+ allowedExternalIds: [],
58
+ /**
59
+ * Engines held in the pool. Each is a full copy of the weights in VRAM and a
60
+ * full load, but concurrent generations each get their own ~10 tok/s, so this
61
+ * is the only dial that raises total throughput. 2 is the smallest number
62
+ * that delivers any parallelism at all.
63
+ */
64
+ engineCount: 2,
65
+ /**
66
+ * Forward steps per GPU->CPU sync (vLLM's `--num-scheduler-steps`). Decode is
67
+ * sync-bound, not compute-bound, so this is the only dial that raises
68
+ * *single-stream* throughput — `engineCount` raises aggregate throughput.
69
+ *
70
+ * 15 is vLLM's documented cap and this engine's default. Unlike vLLM the
71
+ * win here is quantized by Firefox's 100 ms poll, so the best value is the
72
+ * largest K whose burst still fits inside one tick, and it shrinks as the
73
+ * model grows. See src/engine/multistep.js and `npm run e2e -- --steps`.
74
+ */
75
+ decodeSteps: 15,
76
+ /**
77
+ * `buildParams` puts this on every request, so it shadows whatever
78
+ * `mlc-chat-config.json` ships as the model's own default — unlike `top_p`,
79
+ * which is never injected and so comes from the model. 0.6 is what the
80
+ * Qwen3.8-2B-Distill card asks for; reasoning models in this class are prone
81
+ * to repetition loops when decoding is too close to greedy.
82
+ */
83
+ temperature: 0.6,
84
+ maxTokens: 1024,
85
+ systemPrompt: "",
86
+ };
87
+
88
+ /**
89
+ * Make a base URL absolute, at registration rather than at load.
90
+ *
91
+ * WebLLM's `cleanModelUrl` ends in `new URL(url)` with no base, so it throws on
92
+ * a relative path — `/models/my-model/` fails deep inside the loader, long
93
+ * after the caller could tell why. Resolving here means a relative path works
94
+ * as documented, and a context with no page URL to resolve against says so
95
+ * immediately instead of at load time.
96
+ */
97
+ function absolutize(url, field) {
98
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url;
99
+ const base = globalThis.location?.href;
100
+ if (!base) {
101
+ throw new EngineError(
102
+ ERROR.BAD_REQUEST,
103
+ `\`${field}\` is relative ("${url}") and this context has no page URL to resolve it against. ` +
104
+ "Pass an absolute URL.",
105
+ { field, value: url },
106
+ );
107
+ }
108
+ return new URL(url, base).href;
109
+ }
110
+
111
+ export function baseUrlFor(modelId) {
112
+ return `${VIRTUAL_ORIGIN}/${encodeURIComponent(modelId)}/resolve/main/`;
113
+ }
114
+
115
+ /** Every cache key a record claims, keyed by cache scope. */
116
+ export function groupKeysByScope(record) {
117
+ return {
118
+ [CACHE_CONFIG]: record.keys?.[CACHE_CONFIG] ?? [],
119
+ [CACHE_MODEL]: record.keys?.[CACHE_MODEL] ?? [],
120
+ [CACHE_WASM]: record.keys?.[CACHE_WASM] ?? [],
121
+ };
122
+ }
123
+
124
+ /**
125
+ * WebLLM's own `ModelType` enum, which it reads off the `model_list` entry.
126
+ *
127
+ * This matters for one reason: WebLLM refuses image content on anything not
128
+ * marked `VLM` (`UserMessageContentErrorForNonVLM`). It cannot be inferred —
129
+ * `mlc-chat-config.json` carries the architecture name, not this — so a
130
+ * locally compiled vision model has to declare it or every image is rejected
131
+ * with a confusing error.
132
+ */
133
+ export const MODEL_TYPE = { llm: 0, embedding: 1, vlm: 2 };
134
+
135
+ /** Accepts `"vlm"`, `MODEL_TYPE.vlm`, or nothing. */
136
+ export function toModelType(value) {
137
+ if (value === undefined || value === null) return undefined;
138
+ if (typeof value === "number") return value;
139
+ const known = MODEL_TYPE[String(value).toLowerCase()];
140
+ if (known === undefined) {
141
+ throw new EngineError(
142
+ ERROR.BAD_REQUEST,
143
+ `Unknown modelType "${value}". Expected one of: ${Object.keys(MODEL_TYPE).join(", ")}.`,
144
+ { modelType: value },
145
+ );
146
+ }
147
+ return known;
148
+ }
149
+
150
+ /** How a record's weights are obtained. See the header. */
151
+ export const SOURCE = {
152
+ PREBUILT: "prebuilt",
153
+ REMOTE: "remote",
154
+ INJECTED: "injected",
155
+ };
156
+
157
+ /**
158
+ * Whether this record's bytes live in Cache Storage and nowhere else.
159
+ *
160
+ * The distinction that matters: an injected model that loses its cache is
161
+ * unrecoverable and must be re-ingested, so `verify()` gates its load. A remote
162
+ * or prebuilt one just re-downloads, so eviction is a slow load, not an error.
163
+ */
164
+ export const isInjected = (record) => record?.source === SOURCE.INJECTED;
165
+
166
+ function toModelListEntry(record) {
167
+ return {
168
+ model: record.model,
169
+ model_id: record.model_id,
170
+ model_lib: record.model_lib,
171
+ // Carried through, or WebLLM treats a locally registered VLM as text-only.
172
+ ...(record.model_type !== undefined ? { model_type: record.model_type } : {}),
173
+ ...(record.overrides ? { overrides: record.overrides } : {}),
174
+ ...(record.vram_required_MB ? { vram_required_MB: record.vram_required_MB } : {}),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Shape WebLLM's `appConfig` from the registry, optionally over its own
180
+ * prebuilt list.
181
+ *
182
+ * Registered records win on a model_id collision, so a developer can shadow a
183
+ * prebuilt entry — point `Llama-3.2-1B-Instruct-q4f16_1-MLC` at their own
184
+ * mirror, say — without renaming it and breaking their callers.
185
+ *
186
+ * @param {Array<object>} models registered records
187
+ * @param {{model_list: Array<object>} | null} [prebuilt] WebLLM's `prebuiltAppConfig`
188
+ */
189
+ export function toAppConfig(models, prebuilt = null) {
190
+ const own = models.map(toModelListEntry);
191
+ const owned = new Set(own.map((e) => e.model_id));
192
+ const rest = (prebuilt?.model_list ?? []).filter((e) => !owned.has(e.model_id));
193
+ return { model_list: [...own, ...rest], useIndexedDBCache: false };
194
+ }
195
+
196
+ export function formatBytes(bytes) {
197
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
198
+ const units = ["B", "KB", "MB", "GB", "TB"];
199
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
200
+ return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
201
+ }
202
+
203
+ /**
204
+ * @typedef {object} StorageAdapter
205
+ * @property {(key: string | string[]) => Promise<object>} get
206
+ * @property {(items: object) => Promise<void>} set
207
+ *
208
+ * Two methods, deliberately the exact shape of `browser.storage.local`, so the
209
+ * WebExtension adapter is a passthrough rather than a translation layer. See
210
+ * `src/adapters/` for the three implementations.
211
+ */
212
+
213
+ export class ModelStore {
214
+ /** @type {StorageAdapter} */
215
+ #storage;
216
+
217
+ /** @param {StorageAdapter} storage */
218
+ constructor(storage) {
219
+ if (!storage?.get || !storage?.set) {
220
+ throw new EngineError(ERROR.BAD_REQUEST, "ModelStore needs a StorageAdapter with `get(key)` and `set(obj)`.");
221
+ }
222
+ this.#storage = storage;
223
+ }
224
+
225
+ /** @returns {Promise<Array<object>>} registered model records, newest first. */
226
+ async list() {
227
+ const { [STORAGE_KEY]: models } = await this.#storage.get(STORAGE_KEY);
228
+ return Array.isArray(models) ? models : [];
229
+ }
230
+
231
+ async get(modelId) {
232
+ return (await this.list()).find((m) => m.model_id === modelId);
233
+ }
234
+
235
+ /**
236
+ * Register weights the engine should fetch rather than find already cached.
237
+ *
238
+ * This is the whole "developer configures a read path" surface: `model` is a
239
+ * base URL and nothing more, so an HF repo, your own CDN, a folder served off
240
+ * your own origin and a localhost dev server are all the same call.
241
+ *
242
+ * ```js
243
+ * await store.registerModel({
244
+ * modelId: "Qwen3.8-2B-q4f16_1-MLC",
245
+ * model: "/models/Qwen3.8-2B-q4f16_1-MLC/",
246
+ * modelLib: "/models/Qwen3.8-2B-q4f16_1-MLC/Qwen3.8-2B-q4f16_1-webgpu.wasm",
247
+ * });
248
+ * ```
249
+ *
250
+ * No validation of the URLs happens here — there is nothing to validate
251
+ * without fetching, and WebLLM's loader reports a bad base URL far better
252
+ * than a HEAD request would. Contrast `ingestModelFolder`, which validates
253
+ * exhaustively because it is about to write a gigabyte.
254
+ */
255
+ async registerModel({ modelId, model, modelLib, contextWindow, vramRequiredMB, modelType }) {
256
+ const missing = [
257
+ ["modelId", modelId],
258
+ ["model", model],
259
+ ["modelLib", modelLib],
260
+ ].filter(([, v]) => !v);
261
+ if (missing.length) {
262
+ throw new EngineError(
263
+ ERROR.BAD_REQUEST,
264
+ `registerModel needs ${missing.map(([k]) => `\`${k}\``).join(", ")}. ` +
265
+ "Pass `files` instead to register a local folder.",
266
+ { missing: missing.map(([k]) => k) },
267
+ );
268
+ }
269
+ return this.save({
270
+ model_id: modelId,
271
+ model: absolutize(model, "model"),
272
+ model_lib: absolutize(modelLib, "modelLib"),
273
+ source: SOURCE.REMOTE,
274
+ ...(toModelType(modelType) !== undefined ? { model_type: toModelType(modelType) } : {}),
275
+ ...(contextWindow > 0 ? { overrides: { context_window_size: contextWindow } } : {}),
276
+ ...(vramRequiredMB > 0 ? { vram_required_MB: vramRequiredMB } : {}),
277
+ addedAt: new Date().toISOString(),
278
+ });
279
+ }
280
+
281
+ async save(record) {
282
+ const models = (await this.list()).filter((m) => m.model_id !== record.model_id);
283
+ models.unshift(record);
284
+ await this.#storage.set({ [STORAGE_KEY]: models });
285
+ return record;
286
+ }
287
+
288
+ /**
289
+ * Frees an **injected** model's bytes; keeps the registry entry.
290
+ *
291
+ * Scoped to injected models deliberately. We wrote those artifacts, so we
292
+ * hold the only manifest of them — which also makes this stronger than
293
+ * WebLLM's equivalent *for this case*: `deleteModelAllInfoInCache` reads
294
+ * `tensor-cache.json` to enumerate shards, so it cannot clean up after that
295
+ * file has itself been evicted. An explicit key list has no such failure.
296
+ *
297
+ * Remote and prebuilt models are WebLLM's to delete — it fetched them and
298
+ * derives their keys exactly as its loader did. `ScheduledEngine.evict()`
299
+ * routes to whichever owns the model.
300
+ *
301
+ * @returns {Promise<{freedKeys: number}>}
302
+ */
303
+ async evictInjected(modelId) {
304
+ const record = await this.get(modelId);
305
+ if (!record) throw new EngineError(ERROR.UNKNOWN_MODEL, `Unknown model "${modelId}"`, { modelId });
306
+
307
+ let freedKeys = 0;
308
+ for (const [scope, urls] of Object.entries(groupKeysByScope(record))) {
309
+ if (urls.length === 0) continue;
310
+ const cache = await caches.open(scope);
311
+ for (const url of urls) if (await cache.delete(url)) freedKeys += 1;
312
+ }
313
+ return { freedKeys };
314
+ }
315
+
316
+ /**
317
+ * The registry-only primitive: drops the entry, plus the cache keys an
318
+ * **injected** record lists.
319
+ *
320
+ * A remote record claims no keys, so this frees nothing for it — WebLLM
321
+ * downloaded those bytes and derives their names. Use
322
+ * `ScheduledEngine.remove()` for a full teardown; calling this directly on a
323
+ * remote model leaks its shards, because deleting the record also destroys
324
+ * the only record of where they came from.
325
+ */
326
+ async remove(modelId) {
327
+ const record = await this.get(modelId);
328
+ if (!record) throw new EngineError(ERROR.UNKNOWN_MODEL, `Unknown model "${modelId}"`, { modelId });
329
+
330
+ for (const [scope, urls] of Object.entries(groupKeysByScope(record))) {
331
+ const cache = await caches.open(scope);
332
+ await Promise.all(urls.map((url) => cache.delete(url)));
333
+ }
334
+
335
+ const models = (await this.list()).filter((m) => m.model_id !== modelId);
336
+ await this.#storage.set({ [STORAGE_KEY]: models });
337
+ }
338
+
339
+ /**
340
+ * Confirms the caches still hold everything the record promised.
341
+ *
342
+ * Storage eviction is silent, so this is what stands between a stale registry
343
+ * entry and WebLLM trying to fetch `local-model.invalid` mid-load. Inside a
344
+ * WebExtension with `unlimitedStorage` this was a defensive check; on an
345
+ * ordinary page origin, where a multi-GB model is evictable unless
346
+ * `navigator.storage.persist()` was granted, it is a core mechanism.
347
+ *
348
+ * @returns {Promise<{ok: boolean, missing: string[]}>}
349
+ */
350
+ async verify(record) {
351
+ const missing = [];
352
+ for (const [scope, urls] of Object.entries(groupKeysByScope(record))) {
353
+ if (urls.length === 0) continue;
354
+ const cache = await caches.open(scope);
355
+ const present = new Set((await cache.keys()).map((req) => req.url));
356
+ for (const url of urls) if (!present.has(url)) missing.push(url);
357
+ }
358
+ return { ok: missing.length === 0, missing };
359
+ }
360
+
361
+ async getSettings() {
362
+ const { [SETTINGS_KEY]: settings } = await this.#storage.get(SETTINGS_KEY);
363
+ return { ...DEFAULT_SETTINGS, ...(settings ?? {}) };
364
+ }
365
+
366
+ async setSettings(patch) {
367
+ const next = { ...(await this.getSettings()), ...patch };
368
+ await this.#storage.set({ [SETTINGS_KEY]: next });
369
+ return next;
370
+ }
371
+ }
src/engine/multistep.js ADDED
@@ -0,0 +1,607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Multi-step decoding: N forward steps per GPU->CPU sync.
3
+ *
4
+ * Why this exists: decode here is not compute-bound, it is *sync*-bound. Firefox
5
+ * resolves `onSubmittedWorkDone()` / `mapAsync()` only on a 100 ms poll tick
6
+ * (AI.md, "The 10 tok/s ceiling"), and stock WebLLM needs exactly one sync per
7
+ * token — it reads the sampled token id back to JS before it can build the next
8
+ * step's input. One token per tick = 9.6 tok/s, of which ~7 ms is real compute.
9
+ *
10
+ * The fix is the one vLLM ships as `--num-scheduler-steps`: run K steps before
11
+ * paying the per-batch cost once. What makes it possible here without touching
12
+ * the compiled model is that WebLLM's sampling path is *already* on the GPU —
13
+ * `softmax_with_temperature`, `argsort_probs` and `sample_with_top_p` hand back
14
+ * an int32[1] device tensor, and `Tensor.copyFrom(Tensor)` is a device-to-device
15
+ * copy. So the sampled id feeds straight back into `embed` without ever becoming
16
+ * a JS number:
17
+ *
18
+ * embed -> decode -> penalties -> softmax -> argsort -> sample -> embed -> ...
19
+ *
20
+ * Each step stages its id into its own CPU tensor, and the burst ends with
21
+ * **one** `device.sync()`. tvmjs queues GPU->CPU copies into `pendingGPUToCPUCopy`
22
+ * and only awaits them in `sync()`, so K readbacks still cost one tick.
23
+ *
24
+ * Two things follow from the 100 ms grid, and they are why `steps` is a dial:
25
+ *
26
+ * - The win is quantized, not linear. A burst costs `ceil(K * perStepMs / 100)`
27
+ * ticks, so throughput is a sawtooth and the good values of K are the ones
28
+ * landing just under a boundary. On a 0.8B at ~7.3 ms/step that is K=13
29
+ * (~130 tok/s); K=14 already spills into a second tick and halves it.
30
+ * - The best K shrinks as the model grows, because `perStepMs` grows. A model
31
+ * at 25 ms/step wants K=4, not K=15.
32
+ *
33
+ * Cost of the trick: the sampler cannot see its own output mid-burst. Repetition
34
+ * and presence/frequency penalties use the token history as it stood when the
35
+ * burst started, and stop conditions are only checked after the readback, so a
36
+ * burst can overshoot a stop token and must then be rewound. Both are the same
37
+ * trade vLLM makes. Anything needing per-token CPU feedback (grammar-constrained
38
+ * JSON, logprobs, a logit processor) falls back to single-step, where behaviour
39
+ * is identical to stock WebLLM.
40
+ *
41
+ * The other cost is that all of this drives ~30 undocumented tvmjs internals. A
42
+ * WebLLM upgrade that renames one does not break generation — it turns the fast
43
+ * path off and takes the throughput with it, silently. `PIPELINE_CONTRACT` below
44
+ * is that surface written down and checked against the live pipeline before the
45
+ * first burst, so the failure announces itself instead of being measured months
46
+ * later.
47
+ */
48
+
49
+ /** vLLM's documented sweet spot, and the value this extension ships. */
50
+ export const DEFAULT_DECODE_STEPS = 15;
51
+
52
+ /**
53
+ * Above this, the lookahead thrown away at a stop token outweighs the tick it
54
+ * saves, and the transient logits/argsort buffers stop being free.
55
+ */
56
+ export const MAX_DECODE_STEPS = 32;
57
+
58
+ export const clampSteps = (n) => Math.max(1, Math.min(MAX_DECODE_STEPS, Math.round(Number(n)) || 1));
59
+
60
+ // -------------------------------------------------- the pipeline contract ----
61
+
62
+ /**
63
+ * Every tvmjs pipeline internal a burst drives, and how each must behave.
64
+ *
65
+ * None of these are documented, none are part of WebLLM's public surface, and
66
+ * nothing upstream promises they will keep their names. The contract test checks
67
+ * them against the *bundle* on every `npm test`; this checks them against the
68
+ * *live object*, which is a different question — a member can survive in the
69
+ * bundle and still not be on the pipeline handed to us, if upstream moves it to
70
+ * a subclass, a different pipeline type, or behind a factory.
71
+ *
72
+ * Split three ways because presence alone is not the failure that hurts:
73
+ *
74
+ * - **`calls`** must be callable. A rename here throws, which is the *good*
75
+ * case — it is loud.
76
+ * - **`numbers`** are read arithmetically or incremented in place. This is the
77
+ * silent one: `pipeline.filledKVCacheLength += 1` on a member that no longer
78
+ * exists creates a new property, nothing throws, and the KV cache accounting
79
+ * quietly drifts. A missing `contextWindowSize` makes `burstSize` NaN.
80
+ * - **`reads`** need only exist.
81
+ *
82
+ * `logitProcessor` is deliberately optional: `burstSize` tests it for
83
+ * `undefined`, so absent is the normal case, not a broken one.
84
+ *
85
+ * The list is not maintained by hand — `webllm-contract.test.mjs` derives the
86
+ * set this file actually reaches for from its own source and asserts it matches
87
+ * this declaration exactly, so adding a `pipeline.newThing` without declaring it
88
+ * fails the build.
89
+ */
90
+ export const PIPELINE_CONTRACT = {
91
+ calls: [
92
+ "embed",
93
+ "fKVCacheBeginForward",
94
+ "fKVCacheEndForward",
95
+ "fapplyLogitBias",
96
+ "fapplyPenalty",
97
+ "fargsortProbs",
98
+ "fsampleWithTopP",
99
+ "fsoftmaxWithTemperature",
100
+ "getActiveKVStates",
101
+ "invokeDecode",
102
+ "processNextToken",
103
+ "resetChat",
104
+ "stopped",
105
+ ],
106
+ numbers: [
107
+ "contextWindowSize",
108
+ "curRoundDecodingTotalTime",
109
+ "curRoundDecodingTotalTokens",
110
+ "decodingTotalTime",
111
+ "decodingTotalTokens",
112
+ "filledKVCacheLength",
113
+ "fullVocabSize",
114
+ "slidingWindowSize",
115
+ ],
116
+ reads: [
117
+ "appearedTokensFreq",
118
+ "config",
119
+ "device",
120
+ "outputIds",
121
+ "params",
122
+ "sampleIndices",
123
+ "sampleIndicesDevice",
124
+ "topPDevice",
125
+ "tvm",
126
+ ],
127
+ optional: ["logitProcessor"],
128
+ };
129
+
130
+ /**
131
+ * What this pipeline is missing, as sentences a reader can act on.
132
+ * Empty means a burst is safe to run.
133
+ */
134
+ export function missingPipelineMembers(pipeline) {
135
+ if (!pipeline || typeof pipeline !== "object") return ["the pipeline itself is not an object"];
136
+ const missing = [];
137
+ for (const name of PIPELINE_CONTRACT.calls) {
138
+ if (typeof pipeline[name] !== "function") missing.push(`${name}() is not a function`);
139
+ }
140
+ for (const name of PIPELINE_CONTRACT.numbers) {
141
+ if (typeof pipeline[name] !== "number") missing.push(`${name} is not a number`);
142
+ }
143
+ for (const name of PIPELINE_CONTRACT.reads) {
144
+ if (pipeline[name] === undefined) missing.push(`${name} is missing`);
145
+ }
146
+ return missing;
147
+ }
148
+
149
+ /**
150
+ * Replaces `engine.decode` with a burst-and-drain version.
151
+ *
152
+ * `decode` stays a one-token call — the caller's loop still checks
153
+ * `pipeline.stopped()` between tokens and still emits one chunk per token — but
154
+ * only one call in K actually touches the GPU. The rest drain a buffer.
155
+ *
156
+ * @param {object} engine an MLCEngine; in this project the one inside the worker
157
+ * @param {object} [options]
158
+ * @param {number} [options.steps] forward steps per sync; 1 disables the path
159
+ * @param {(info: {steps: number, tokens: number, ms: number}) => void} [options.onBurst]
160
+ * @param {(info: {missing: string[]}) => void} [options.onFallback] fired once
161
+ * per pipeline that fails the contract, before it is routed to stock decoding
162
+ * @returns {{setSteps: (n: number) => void, readonly steps: number,
163
+ * readonly fallbacks: number}}
164
+ */
165
+ export function installMultiStepDecoding(
166
+ engine,
167
+ { steps = DEFAULT_DECODE_STEPS, onBurst, onFallback } = {},
168
+ ) {
169
+ const config = { steps: clampSteps(steps), fallbacks: 0 };
170
+ const lookahead = new WeakMap();
171
+ const baseDecode = engine.decode.bind(engine);
172
+ const basePrefill = engine.prefill.bind(engine);
173
+
174
+ const stateFor = (pipeline) => {
175
+ let state = lookahead.get(pipeline);
176
+ if (!state) lookahead.set(pipeline, (state = { queue: [] }));
177
+ return state;
178
+ };
179
+
180
+ /** Contract verdict per pipeline; the check runs once, the answer is reused. */
181
+ const supported = new WeakMap();
182
+
183
+ /**
184
+ * Whether this pipeline may be burst, decided once and remembered.
185
+ *
186
+ * Checked at first decode rather than at install time because there is no
187
+ * pipeline yet when this function runs — the engine gets one per `reload()`,
188
+ * and hands it to us as an argument. So the guard lives at the first place a
189
+ * pipeline is ever seen.
190
+ *
191
+ * Failing here means an upgrade moved something and multi-step decoding is
192
+ * gone. Stock decoding still produces correct tokens, so the danger is not a
193
+ * crash but silence: ~18.4 -> ~9.7 tok/s with nothing in the log to explain
194
+ * it. Hence one loud report, and a `fallbacks` count the worker can surface.
195
+ */
196
+ const canBurst = (pipeline) => {
197
+ const known = supported.get(pipeline);
198
+ if (known !== undefined) return known;
199
+
200
+ const missing = missingPipelineMembers(pipeline);
201
+ supported.set(pipeline, missing.length === 0);
202
+ if (missing.length > 0) {
203
+ config.fallbacks += 1;
204
+ console.error(
205
+ "[everything-webgpu] multi-step decoding disabled — falling back to stock " +
206
+ "single-step decode. Generation stays correct, throughput roughly halves.\n" +
207
+ ` The pipeline is missing ${missing.length} of the internals a burst drives:\n` +
208
+ missing.map((line) => ` - ${line}`).join("\n") +
209
+ "\n This is what a WebLLM upgrade looks like from here. `npm test` " +
210
+ "(webllm-contract) says whether the names are gone from the bundle too.",
211
+ );
212
+ onFallback?.({ missing });
213
+ }
214
+ return missing.length === 0;
215
+ };
216
+
217
+ // A round can end with tokens still buffered — a stop token mid-burst, or an
218
+ // interrupt that breaks the caller's loop. Those tokens are already in the KV
219
+ // cache, so they must come back out before the next round reuses it.
220
+ engine.prefill = async (input, pipeline, chatConfig, genConfig) => {
221
+ discardLookahead(pipeline, stateFor(pipeline));
222
+ return basePrefill(input, pipeline, chatConfig, genConfig);
223
+ };
224
+
225
+ engine.decode = async (pipeline, genConfig) => {
226
+ const state = stateFor(pipeline);
227
+
228
+ if (state.queue.length === 0) {
229
+ // Before the first burst on this pipeline, not before every one: the
230
+ // verdict is cached, so a healthy pipeline pays one property scan for the
231
+ // whole conversation.
232
+ if (!canBurst(pipeline)) return baseDecode(pipeline, genConfig);
233
+
234
+ const burst = burstSize(pipeline, genConfig, config.steps);
235
+ if (burst <= 1) return baseDecode(pipeline, genConfig);
236
+
237
+ const probe = {};
238
+ const tstart = performance.now();
239
+ state.queue = await sampleBurst(pipeline, genConfig, burst, probe);
240
+ const ms = performance.now() - tstart;
241
+
242
+ // One burst is one wall-clock cost; its tokens are counted as they drain,
243
+ // so a rewound overshoot never inflates the reported rate.
244
+ pipeline.decodingTotalTime += ms / 1e3;
245
+ pipeline.curRoundDecodingTotalTime += ms / 1e3;
246
+ onBurst?.({ steps: burst, tokens: state.queue.length, ms, ...probe });
247
+ }
248
+
249
+ const token = state.queue.shift();
250
+ pipeline.decodingTotalTokens += 1;
251
+ pipeline.curRoundDecodingTotalTokens += 1;
252
+ pipeline.processNextToken(token, genConfig);
253
+
254
+ // The burst ran past a stop token; nothing after it was ever emitted.
255
+ if (pipeline.stopped() && state.queue.length > 0) discardLookahead(pipeline, state);
256
+ };
257
+
258
+ return {
259
+ setSteps: (n) => void (config.steps = clampSteps(n)),
260
+ get steps() {
261
+ return config.steps;
262
+ },
263
+ /** Pipelines that failed the contract. Non-zero means the fast path is off. */
264
+ get fallbacks() {
265
+ return config.fallbacks;
266
+ },
267
+ };
268
+ }
269
+
270
+ // ------------------------------------------------------------- burst size ---
271
+
272
+ /**
273
+ * How many steps may run before the next stop condition *has* to be checked.
274
+ *
275
+ * `max_tokens` and the context window are countable, so they are clamped rather
276
+ * than overshot — which leaves stop tokens as the only reason a burst is ever
277
+ * rewound. Returns 1 when multi-step cannot be used at all, routing the caller
278
+ * to stock single-step decoding.
279
+ */
280
+ export function burstSize(pipeline, genConfig, steps) {
281
+ if (steps <= 1) return 1;
282
+
283
+ // Per-token CPU feedback: the next step's logits depend on a JS-side decision
284
+ // about this step's token, so there is nothing to overlap.
285
+ const format = genConfig?.response_format?.type;
286
+ if (format === "json_object" || format === "grammar" || format === "structural_tag") return 1;
287
+ if (genConfig?.logprobs) return 1;
288
+ if (pipeline.logitProcessor !== undefined) return 1;
289
+
290
+ const maxTokens = genConfig?.max_tokens;
291
+ const untilMax = maxTokens ? maxTokens - pipeline.outputIds.length : Infinity;
292
+ const untilContextEnd =
293
+ pipeline.slidingWindowSize === -1
294
+ ? pipeline.contextWindowSize - pipeline.filledKVCacheLength
295
+ : Infinity;
296
+
297
+ return Math.max(1, Math.min(steps, untilMax, untilContextEnd));
298
+ }
299
+
300
+ // ----------------------------------------------------------------- burst ----
301
+
302
+ /**
303
+ * Runs `steps` forward+sample steps with no GPU->CPU sync between them, then
304
+ * pays exactly one.
305
+ *
306
+ * @returns {Promise<number[]>} the sampled token ids, in order
307
+ */
308
+ async function sampleBurst(pipeline, genConfig, steps, out) {
309
+ const { tvm, device } = pipeline;
310
+ let probe = null;
311
+ const vocab = pipeline.fullVocabSize;
312
+ const sampling = resolveSampling(pipeline, genConfig);
313
+
314
+ tvm.beginScope();
315
+ let temperatures;
316
+ let bias;
317
+ let penalty;
318
+ /** The last committed token, which seeds step 0. Owned here, not by a scope. */
319
+ let seedTokens;
320
+ try {
321
+ temperatures = tvm.detachFromCurrentScope(
322
+ tvm.empty([1], "float32", device).copyFrom([Math.max(1e-6, sampling.temperature)]),
323
+ );
324
+ bias = makeLogitBias(pipeline, sampling);
325
+ penalty = makePenalty(pipeline, sampling);
326
+ // top_p lives in a tensor the pipeline owns and reuses, set up exactly as
327
+ // `sampleTokenFromLogits` does. It is constant for the whole burst.
328
+ const topPHost = new Float32Array(pipeline.topPDevice.shape[0]).fill(-1);
329
+ const topP = Math.max(sampling.top_p, 1e-5);
330
+ pipeline.sampleIndices.forEach((row) => (topPHost[row] = topP));
331
+ pipeline.topPDevice.copyFrom(topPHost);
332
+ seedTokens = tvm.detachFromCurrentScope(
333
+ tvm.empty([1], "int32", device).copyFrom([pipeline.outputIds[pipeline.outputIds.length - 1]]),
334
+ );
335
+ } finally {
336
+ tvm.endScope();
337
+ }
338
+ let tokens = seedTokens;
339
+
340
+ /**
341
+ * Sampled ids stay on the device for the whole loop; the host copies happen
342
+ * after it, never interleaved with compute.
343
+ *
344
+ * The order is load-bearing. `flushCommands()` nulls tvmjs's
345
+ * `pendingGPUToCPUCopy` whenever it submits an encoder, and every GPU->CPU
346
+ * copy calls it. Interleaving copies with compute therefore made each step
347
+ * discard the previous step's pending readback, leaving `device.sync()`
348
+ * awaiting only the last one — correct in practice only because the
349
+ * `mapAsync` promises happen to resolve in FIFO order. Doing all the copies
350
+ * after the loop means the first flushes and starts the chain while the rest
351
+ * find no pending encoder, so the chain accumulates intact.
352
+ */
353
+ const sampledIds = [];
354
+ /** One CPU int32[1] per step. All of their reads land on the same poll tick. */
355
+ const staged = [];
356
+
357
+ // The decisive probe. The K-step loop below contains no `await`, so it is one
358
+ // synchronous JS turn: everything it costs is content-process CPU — command
359
+ // encoding, `createBindGroup`, IPC to the GPU process. The `await` after it is
360
+ // everything else: GPU execution plus the wait for the next 100 ms poll tick.
361
+ // Splitting the two says which one the budget actually goes to.
362
+ const gpuCtx = tvm.lib?.webGPUContext;
363
+ const dispatchesBefore = gpuCtx?.shaderSubmitCounter ?? 0;
364
+ const flushesBefore = countFlushes(gpuCtx);
365
+ let forwardDispatches = 0;
366
+ const tEncodeStart = performance.now();
367
+
368
+ try {
369
+ for (let step = 0; step < steps; step++) {
370
+ tvm.beginScope();
371
+ const stepStart = gpuCtx?.shaderSubmitCounter ?? 0;
372
+ try {
373
+ // `tokens` is owned by `sampledIds` (or is the seed), not by this scope.
374
+ const embeddings = pipeline.embed(tokens, pipeline.params);
375
+ const batched = embeddings.view([1].concat(embeddings.shape));
376
+
377
+ const states = pipeline.getActiveKVStates();
378
+ const seqIds = tvm.makeShapeTuple([0]);
379
+ const inputLen = tvm.makeShapeTuple([1]);
380
+ for (const state of states) pipeline.fKVCacheBeginForward(state, seqIds, inputLen);
381
+ const forwarded = pipeline.invokeDecode(batched);
382
+ for (let i = states.length - 1; i >= 0; i--) pipeline.fKVCacheEndForward(states[i]);
383
+ pipeline.filledKVCacheLength += 1;
384
+
385
+ // Split the launch count at the forward/sample boundary. The sampling
386
+ // tail is `argsort_probs` over the full vocab (248k here), which is a
387
+ // multi-pass sort and belongs to the runtime, not the model — so it is
388
+ // worth knowing how much of the per-token kernel budget it owns.
389
+ forwardDispatches += (gpuCtx?.shaderSubmitCounter ?? 0) - stepStart;
390
+
391
+ const logits = forwarded.get(0);
392
+ if (bias) {
393
+ pipeline.fapplyLogitBias(logits.view([1, vocab]), bias.pos2seqIds, bias.tokenIds, bias.values);
394
+ }
395
+ if (penalty) {
396
+ pipeline.fapplyPenalty(
397
+ logits.view([1, vocab]),
398
+ penalty.seqIds,
399
+ penalty.pos2seqIds,
400
+ penalty.tokenIds,
401
+ penalty.counts,
402
+ penalty.penalties,
403
+ );
404
+ }
405
+
406
+ const probs = pipeline
407
+ .fsoftmaxWithTemperature(logits.view([1, 1, vocab]), temperatures)
408
+ .view([1, vocab]);
409
+ const sorted = pipeline.fargsortProbs(probs);
410
+ const sampled = pipeline.fsampleWithTopP(
411
+ sorted.get(0),
412
+ sorted.get(1),
413
+ tvm.uniform([1], 0, 1, device),
414
+ pipeline.sampleIndicesDevice,
415
+ pipeline.topPDevice,
416
+ );
417
+
418
+ tokens = tvm.detachFromCurrentScope(sampled);
419
+ sampledIds.push(tokens);
420
+ } finally {
421
+ tvm.endScope();
422
+ }
423
+ }
424
+
425
+ // Every readback together, after all compute: one flush, one intact chain.
426
+ tvm.beginScope();
427
+ try {
428
+ for (const id of sampledIds) {
429
+ staged.push(tvm.detachFromCurrentScope(tvm.empty([1], "int32", tvm.cpu()).copyFrom(id)));
430
+ }
431
+ } finally {
432
+ tvm.endScope();
433
+ }
434
+
435
+ // Encoding the copies is still CPU work, so the boundary sits after them.
436
+ const tEncoded = performance.now();
437
+
438
+ // The one sync the whole burst pays for.
439
+ await device.sync();
440
+
441
+ probe = {
442
+ encodeMs: tEncoded - tEncodeStart,
443
+ syncMs: performance.now() - tEncoded,
444
+ dispatches: (gpuCtx?.shaderSubmitCounter ?? 0) - dispatchesBefore,
445
+ forwardDispatches,
446
+ flushes: countFlushes(gpuCtx) - flushesBefore,
447
+ };
448
+ return staged.map((host) => host.toArray()[0]);
449
+ } finally {
450
+ if (probe) Object.assign(out, probe);
451
+ for (const host of staged) host.dispose();
452
+ for (const id of sampledIds) id.dispose();
453
+ seedTokens?.dispose();
454
+ temperatures.dispose();
455
+ disposeAll(bias);
456
+ disposeAll(penalty);
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Kernel launches per `flushCommands()`, which decides whether batching tvmjs's
462
+ * per-kernel compute passes into one pass is worth anything.
463
+ *
464
+ * `flushCommands()` submits the pending encoder — so it would also close a
465
+ * shared pass — and it fires from `deviceFreeDataSpace`, the buffer copies and
466
+ * `sync`. If TVM frees an intermediate between every op then flushes ≈ kernels,
467
+ * the pass stream is already chopped up, and there is nothing to merge. tvmjs
468
+ * keeps no counter of its own, so wrap the method once per context.
469
+ */
470
+ function countFlushes(gpuCtx) {
471
+ if (!gpuCtx) return 0;
472
+ if (gpuCtx.__ewgpuFlushCount === undefined) {
473
+ const base = gpuCtx.flushCommands.bind(gpuCtx);
474
+ gpuCtx.__ewgpuFlushCount = 0;
475
+ gpuCtx.flushCommands = () => {
476
+ gpuCtx.__ewgpuFlushCount += 1;
477
+ base();
478
+ };
479
+ }
480
+ return gpuCtx.__ewgpuFlushCount;
481
+ }
482
+
483
+ // ---------------------------------------------------------------- rewind ----
484
+
485
+ /**
486
+ * Drops un-emitted lookahead and takes it back out of the KV cache.
487
+ *
488
+ * `kv_state_popn` is the clean path. If the runtime has not registered it the
489
+ * cache cannot be trimmed, so it is thrown away instead: the next round pays a
490
+ * full re-prefill (one sync, not one per token) rather than attending over
491
+ * tokens the caller never saw.
492
+ */
493
+ function discardLookahead(pipeline, state) {
494
+ const n = state.queue.length;
495
+ state.queue = [];
496
+ if (n === 0) return;
497
+
498
+ const { tvm } = pipeline;
499
+ try {
500
+ const popn = getPopN(pipeline);
501
+ if (popn) {
502
+ tvm.beginScope();
503
+ try {
504
+ for (const kvState of pipeline.getActiveKVStates()) {
505
+ popn(kvState, tvm.scalar(0, "int64"), tvm.scalar(n, "int32"));
506
+ }
507
+ } finally {
508
+ tvm.endScope();
509
+ }
510
+ pipeline.filledKVCacheLength -= n;
511
+ return;
512
+ }
513
+ } catch {
514
+ // Fall through: a trim that threw is handled the same as no trim at all.
515
+ }
516
+ pipeline.resetChat(/* keepStats= */ true);
517
+ }
518
+
519
+ const popNCache = new WeakMap();
520
+
521
+ function getPopN(pipeline) {
522
+ if (popNCache.has(pipeline)) return popNCache.get(pipeline);
523
+ let popn = null;
524
+ const { tvm } = pipeline;
525
+ tvm.beginScope();
526
+ try {
527
+ popn = tvm.detachFromCurrentScope(tvm.getGlobalFunc("vm.builtin.kv_state_popn"));
528
+ } catch {
529
+ popn = null;
530
+ } finally {
531
+ tvm.endScope();
532
+ }
533
+ popNCache.set(pipeline, popn);
534
+ return popn;
535
+ }
536
+
537
+ // ------------------------------------------------------- sampling inputs ----
538
+
539
+ /**
540
+ * The subset of `sampleTokenFromLogits`'s config resolution a burst can honour,
541
+ * in the same precedence order: the request overrides `mlc-chat-config.json`.
542
+ */
543
+ function resolveSampling(pipeline, genConfig) {
544
+ const has = (v) => v !== undefined && v !== null;
545
+ const pick = (key, fallback) => (has(genConfig?.[key]) ? genConfig[key] : fallback);
546
+ return {
547
+ temperature: pick("temperature", pipeline.config.temperature),
548
+ top_p: pick("top_p", pipeline.config.top_p) ?? 1,
549
+ repetition_penalty: pick("repetition_penalty", pipeline.config.repetition_penalty),
550
+ frequency_penalty: pick("frequency_penalty", pipeline.config.frequency_penalty) ?? 0,
551
+ presence_penalty: pick("presence_penalty", pipeline.config.presence_penalty) ?? 0,
552
+ logit_bias: pick("logit_bias", undefined),
553
+ };
554
+ }
555
+
556
+ /** Static for the whole request, so it is uploaded once and reused every step. */
557
+ function makeLogitBias(pipeline, { logit_bias }) {
558
+ const ids = Object.keys(logit_bias ?? {});
559
+ if (ids.length === 0) return null;
560
+ const { tvm, device } = pipeline;
561
+ const int32 = (values) =>
562
+ tvm.detachFromCurrentScope(tvm.empty([values.length], "int32", device).copyFrom(values));
563
+ return {
564
+ pos2seqIds: int32(new Int32Array(ids.length)),
565
+ tokenIds: int32(Int32Array.from(ids, (id) => parseInt(id, 10))),
566
+ values: tvm.detachFromCurrentScope(
567
+ tvm.empty([ids.length], "float32", device).copyFrom(Float32Array.from(ids, (id) => logit_bias[id])),
568
+ ),
569
+ };
570
+ }
571
+
572
+ /**
573
+ * Frozen token history for the burst.
574
+ *
575
+ * This is the one place multi-step is not equivalent to single-step: tokens
576
+ * sampled *within* a burst are not penalised against each other, because their
577
+ * ids are still on the GPU. At K=15 the penalty state is at most 15 tokens
578
+ * stale. Anything that cannot tolerate that should run with `decodeSteps: 1`.
579
+ */
580
+ function makePenalty(pipeline, { repetition_penalty, frequency_penalty, presence_penalty }) {
581
+ const active = frequency_penalty !== 0 || presence_penalty !== 0 || (repetition_penalty ?? 1) !== 1;
582
+ if (!active) return null;
583
+
584
+ const appeared = [...pipeline.appearedTokensFreq.keys()];
585
+ if (appeared.length === 0) return null;
586
+ const freqs = [...pipeline.appearedTokensFreq.values()];
587
+
588
+ const { tvm, device } = pipeline;
589
+ const int32 = (values) =>
590
+ tvm.detachFromCurrentScope(tvm.empty([values.length], "int32", device).copyFrom(values));
591
+ return {
592
+ seqIds: int32(new Int32Array(1)),
593
+ pos2seqIds: int32(new Int32Array(appeared.length)),
594
+ tokenIds: int32(Int32Array.from(appeared)),
595
+ counts: int32(Int32Array.from(freqs)),
596
+ penalties: tvm.detachFromCurrentScope(
597
+ tvm
598
+ .empty([1, 3], "float32", device)
599
+ .copyFrom(new Float32Array([presence_penalty, frequency_penalty, repetition_penalty ?? 1])),
600
+ ),
601
+ };
602
+ }
603
+
604
+ function disposeAll(inputs) {
605
+ if (!inputs) return;
606
+ for (const tensor of Object.values(inputs)) tensor.dispose();
607
+ }
src/engine/pool.js ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Engine pool + priority scheduler.
3
+ *
4
+ * Why this exists: the GPU gives us ~10 completion ticks per second, shared by
5
+ * every caller (AI.md, "The 10 tok/s ceiling"). Two consequences drive the
6
+ * whole design:
7
+ *
8
+ * - A single generation can never beat ~10 tok/s. When decode is *sync-bound*
9
+ * - waiting on the ~100 ms tick rather than on the GPU - N concurrent ones
10
+ * each still get ~10 tok/s, because they all wake on the same tick. That
11
+ * held for the 0.8B (1.3-2.0x on two engines) and stops holding once the
12
+ * model is big enough to keep the GPU busy: measured 1.06x on the 2B, where
13
+ * two streams simply run at half speed each. Fan-out is worth having, but
14
+ * the second engine earns its memory through isolation, not throughput.
15
+ * - Every engine in the pool holds its own copy of the weights, so the pool is
16
+ * small and its slots are the scarce resource worth scheduling.
17
+ *
18
+ * The scheduler is therefore four mechanisms and no more:
19
+ * 1. Priority bands, FIFO within a band.
20
+ * 2. Session supersession - a new job with the same `session` cancels the
21
+ * previous one. This is the ghost-text primitive: every keystroke replaces
22
+ * the last request instead of queueing behind it.
23
+ * 3. Opt-in preemption - an `interactive` job with no free slot may interrupt
24
+ * a running job that declared `preemptible`. The victim resolves with what
25
+ * it produced so far, so it is never requeued and can never starve.
26
+ * 4. Demand-driven growth - the pool starts at one engine and earns another
27
+ * only when a *different* task is waiting on a busy pool. See `#grow`.
28
+ *
29
+ * Jobs carry a `task`: the unit that owns an engine. Every item of one batch
30
+ * shares it, so "translate this page" is one task however many requests it is.
31
+ * Slots are handed out per job, but the last free slot is reserved per task -
32
+ * otherwise one batch fills the pool and ghost-text starves behind it.
33
+ *
34
+ * `createEngine` is injected so the scheduler can be tested without a GPU.
35
+ */
36
+ import { JOB_KIND, PRIORITY, PRIORITY_ORDER } from "./constants.js";
37
+
38
+ let nextJobId = 0;
39
+
40
+ export class EnginePool {
41
+ #maxSize;
42
+ #createEngine;
43
+ #onStateChange;
44
+ #slots = [];
45
+ #queues = new Map(PRIORITY_ORDER.map((p) => [p, []]));
46
+ #bySession = new Map();
47
+ #loading = null;
48
+ #growing = null;
49
+ /** Set to the failure reason once a grow attempt fails; growth then stops. */
50
+ #growthBlocked = null;
51
+ /**
52
+ * Bumped by `unload()`. An engine that finishes loading against a stale
53
+ * generation is torn down rather than installed — see `load()`.
54
+ */
55
+ #generation = 0;
56
+
57
+ constructor({ size = 2, createEngine, onStateChange = () => {} }) {
58
+ this.#maxSize = Math.max(1, Math.min(4, size));
59
+ this.#createEngine = createEngine;
60
+ this.#onStateChange = onStateChange;
61
+ }
62
+
63
+ /** Engines that exist right now, which is not the same as the cap. */
64
+ get size() {
65
+ return this.#slots.length;
66
+ }
67
+
68
+ get maxSize() {
69
+ return this.#maxSize;
70
+ }
71
+
72
+ get loaded() {
73
+ return this.#slots.length > 0;
74
+ }
75
+
76
+ /**
77
+ * Brings up the first engine, and only the first.
78
+ *
79
+ * The pool used to build every engine here. It no longer does, because an
80
+ * engine is a full copy of the weights - measured ~1.6 GB steady state for
81
+ * the 0.8B, ~2.4 GB for a 2B - and staging a second one costs that much host
82
+ * memory before the GPU ever sees it. On a 16 GB machine that is the
83
+ * difference between working and swapping, and it was being paid up front
84
+ * whether or not two tasks ever ran at once. `#grow` earns the rest.
85
+ */
86
+ async load(onProgress = () => {}) {
87
+ if (this.#loading) return this.#loading;
88
+
89
+ const generation = this.#generation;
90
+ this.#loading = (async () => {
91
+ const engine = await this.#createEngine(0, (report) =>
92
+ onProgress({ ...report, engine: 1, engines: 1 }),
93
+ );
94
+ // `unload()` may have run while this was still building — a cancelled
95
+ // load, or a model switch. Installing it now would resurrect an engine
96
+ // nobody holds a reference to, leaking its worker and a full copy of the
97
+ // weights. `#grow()` has always guarded this; `load()` did not.
98
+ if (generation !== this.#generation) {
99
+ await engine.unload?.().catch(() => {});
100
+ return 0;
101
+ }
102
+ this.#slots = [{ engine, job: null }];
103
+ return this.#slots.length;
104
+ })().finally(() => {
105
+ this.#loading = null;
106
+ });
107
+
108
+ return this.#loading;
109
+ }
110
+
111
+ async unload() {
112
+ this.#generation += 1;
113
+ for (const job of this.#allJobs()) this.#finish(job, { cancelled: true });
114
+ for (const p of this.#queues.values()) p.length = 0;
115
+ this.#bySession.clear();
116
+ // A smaller model may well fit where this one did not.
117
+ this.#growthBlocked = null;
118
+ const slots = this.#slots;
119
+ this.#slots = [];
120
+ await Promise.all(slots.map((s) => s.engine.unload?.().catch(() => {})));
121
+ }
122
+
123
+ /**
124
+ * @param {object} spec
125
+ * @param {object} spec.params passed straight to `engine.chat.completions.create`
126
+ * @param {string} [spec.task] the unit that owns an engine; a whole batch shares one
127
+ * @param {string} [spec.session] later jobs with this session supersede earlier ones
128
+ * @param {string} [spec.priority] one of PRIORITY
129
+ * @param {boolean} [spec.preemptible] may be interrupted by an interactive job
130
+ * @param {(chunk: object) => void} [spec.onChunk] WebLLM's chunk, verbatim
131
+ * @returns {Promise<{text: string, usage?: object, cancelled?: boolean, preempted?: boolean}>}
132
+ */
133
+ submit(spec) {
134
+ const priority = PRIORITY_ORDER.includes(spec.priority) ? spec.priority : PRIORITY.NORMAL;
135
+ const id = spec.id ?? `job-${++nextJobId}`;
136
+ const job = {
137
+ id,
138
+ // Unlabelled work is its own task, so two bare `chat` calls still compete
139
+ // for separate engines the way two different callers would.
140
+ task: spec.task ?? spec.session ?? id,
141
+ session: spec.session,
142
+ priority,
143
+ preemptible: Boolean(spec.preemptible),
144
+ // What the slot's engine should be asked to do. Everything else about a
145
+ // job — priority, supersession, preemption, one-task-one-engine — is the
146
+ // same either way, which is why this is a field and not a second pool.
147
+ kind: spec.kind === JOB_KIND.EMBEDDING ? JOB_KIND.EMBEDDING : JOB_KIND.CHAT,
148
+ params: spec.params,
149
+ onChunk: spec.onChunk ?? (() => {}),
150
+ text: "",
151
+ slot: null,
152
+ done: false,
153
+ };
154
+ job.promise = new Promise((resolve) => (job.resolve = resolve));
155
+
156
+ if (job.session) {
157
+ const previous = this.#bySession.get(job.session);
158
+ // Superseded, not queued behind: the keystroke that produced the old
159
+ // request is already stale.
160
+ if (previous) this.cancel(previous.id);
161
+ this.#bySession.set(job.session, job);
162
+ }
163
+
164
+ this.#queues.get(priority).push(job);
165
+ this.#pump();
166
+ this.#emit();
167
+ return job.promise;
168
+ }
169
+
170
+ /** Cancels by job id or by session key. Returns how many jobs it stopped. */
171
+ cancel(idOrSession) {
172
+ let stopped = 0;
173
+ for (const job of this.#allJobs()) {
174
+ if (job.id !== idOrSession && job.session !== idOrSession) continue;
175
+ stopped += 1;
176
+ if (job.slot === null) {
177
+ this.#dequeue(job);
178
+ this.#finish(job, { cancelled: true });
179
+ } else {
180
+ job.cancelling = true;
181
+ this.#slots[job.slot].engine.interruptGenerate();
182
+ }
183
+ }
184
+ if (stopped) this.#emit();
185
+ return stopped;
186
+ }
187
+
188
+ /**
189
+ * Pushes a runtime setting to every engine that accepts one.
190
+ *
191
+ * Separate from `load()` because the settings this carries — `decodeSteps` so
192
+ * far — are per-generation knobs, not per-model ones: retuning them must not
193
+ * cost a reload of the weights.
194
+ */
195
+ configure(patch) {
196
+ let applied = 0;
197
+ for (const slot of this.#slots) {
198
+ if (typeof slot.engine.configure !== "function") continue;
199
+ slot.engine.configure(patch);
200
+ applied += 1;
201
+ }
202
+ return applied;
203
+ }
204
+
205
+ status() {
206
+ return {
207
+ size: this.#slots.length,
208
+ maxSize: this.#maxSize,
209
+ growing: this.#growing !== null,
210
+ // Non-null once a second engine failed to come up; the UI can say why the
211
+ // pool is smaller than the cap instead of looking stuck.
212
+ growthBlocked: this.#growthBlocked,
213
+ busy: this.#slots.filter((s) => s.job !== null).length,
214
+ queued: PRIORITY_ORDER.reduce((n, p) => n + this.#queues.get(p).length, 0),
215
+ queuedByPriority: Object.fromEntries(
216
+ PRIORITY_ORDER.map((p) => [p, this.#queues.get(p).length]),
217
+ ),
218
+ };
219
+ }
220
+
221
+ // ------------------------------------------------------------ internals ---
222
+
223
+ /**
224
+ * Adds one engine, but only when a second one would actually buy something.
225
+ *
226
+ * There is no budget to check against: Firefox implements neither
227
+ * `navigator.deviceMemory` nor `performance.memory`, and `storage.estimate()`
228
+ * measures disk quota, not RAM. Nothing reports free memory to an extension.
229
+ *
230
+ * So the pool does not predict, it probes - and it only probes when the
231
+ * answer matters. A failed load is taken as the answer and is not retried:
232
+ * the retry would cost another full staging pass to learn the same thing.
233
+ */
234
+ #grow() {
235
+ if (this.#growing || this.#growthBlocked || this.#loading) return;
236
+ if (this.#slots.length >= this.#maxSize) return;
237
+ if (!this.#crossTaskDemand()) return;
238
+
239
+ const index = this.#slots.length;
240
+ this.#growing = (async () => {
241
+ try {
242
+ const engine = await this.#createEngine(index, () => {});
243
+ // `unload()` may have emptied the pool while this was still loading;
244
+ // pushing then would resurrect a slot for a model nobody asked for.
245
+ if (this.#slots.length === index) this.#slots.push({ engine, job: null });
246
+ else await engine.unload?.().catch(() => {});
247
+ } catch (err) {
248
+ // Almost always memory. Staying at the current size is the right
249
+ // outcome, not an error owed to whichever job happened to trigger it.
250
+ this.#growthBlocked = err?.message ?? String(err);
251
+ }
252
+ })().finally(() => {
253
+ this.#growing = null;
254
+ this.#pump();
255
+ this.#emit();
256
+ });
257
+ }
258
+
259
+ /**
260
+ * True when a queued job belongs to a task that is not already running.
261
+ *
262
+ * This is the entire growth policy. A second engine exists so that a
263
+ * translation and a ghost-text completion can run at once - not to make one
264
+ * batch finish sooner. Four queued items of the same batch keep the pool at
265
+ * one engine; one queued completion alongside them grows it.
266
+ */
267
+ #crossTaskDemand() {
268
+ const running = this.#runningTasks();
269
+ if (running.size === 0) return false;
270
+ for (const job of this.#queued()) if (!running.has(job.task)) return true;
271
+ return false;
272
+ }
273
+
274
+ #runningTasks() {
275
+ const tasks = new Set();
276
+ for (const slot of this.#slots) if (slot.job) tasks.add(slot.job.task);
277
+ return tasks;
278
+ }
279
+
280
+ *#queued() {
281
+ for (const p of PRIORITY_ORDER) yield* this.#queues.get(p);
282
+ }
283
+
284
+ *#allJobs() {
285
+ for (const slot of this.#slots) if (slot.job) yield slot.job;
286
+ yield* this.#queued();
287
+ }
288
+
289
+ #dequeue(job) {
290
+ const queue = this.#queues.get(job.priority);
291
+ const i = queue.indexOf(job);
292
+ if (i >= 0) queue.splice(i, 1);
293
+ }
294
+
295
+ #nextJob() {
296
+ for (const p of PRIORITY_ORDER) {
297
+ const queue = this.#queues.get(p);
298
+ if (queue.length) return queue[0];
299
+ }
300
+ return null;
301
+ }
302
+
303
+ #pump() {
304
+ for (;;) {
305
+ const slot = this.#slots.findIndex((s) => s.job === null);
306
+ const job = slot >= 0 ? this.#pick() : this.#nextJob();
307
+ if (!job) break;
308
+
309
+ if (slot >= 0) {
310
+ this.#dequeue(job);
311
+ this.#start(slot, job);
312
+ continue;
313
+ }
314
+
315
+ // No slot. Only an interactive job is allowed to take one by force, and
316
+ // only from a job that opted in.
317
+ if (job.priority !== PRIORITY.INTERACTIVE) break;
318
+ const victim = this.#slots.find((s) => s.job?.preemptible && !s.job.cancelling);
319
+ if (!victim) break;
320
+ victim.job.preempting = true;
321
+ victim.job.cancelling = true;
322
+ victim.engine.interruptGenerate();
323
+ break; // the freed slot re-enters #pump when the victim settles
324
+ }
325
+ this.#grow();
326
+ }
327
+
328
+ /**
329
+ * Which queued job takes a free slot: the first, by priority, whose task is
330
+ * not already running.
331
+ *
332
+ * **One task holds at most one engine.** Letting a batch spread over the pool
333
+ * used to be the point - it was worth 1.3-2.0x on the 0.8B, where decode was
334
+ * sync-bound and a second stream filled idle GPU. On the 2B it is worth
335
+ * 1.06x: the GPU is busy, so two streams of the same work just run at half
336
+ * speed each (measured; `engine scaling:` in the e2e). That buys nothing, and
337
+ * it costs the thing a second engine is actually for - a page translation
338
+ * would sit on both engines and ghost-text would wait behind it.
339
+ *
340
+ * So the rule is flat, and two runnable tasks are therefore always running at
341
+ * once whenever two engines exist. An engine may idle while one task has work
342
+ * queued; that is the ~6% being deliberately given up.
343
+ *
344
+ * `interactive` is the one exception: that band exists for work a human is
345
+ * waiting on keystroke-by-keystroke, and it takes a free engine regardless.
346
+ */
347
+ #pick() {
348
+ const head = this.#nextJob();
349
+ if (!head || head.priority === PRIORITY.INTERACTIVE) return head;
350
+
351
+ const running = this.#runningTasks();
352
+ for (const job of this.#queued()) if (!running.has(job.task)) return job;
353
+ return null;
354
+ }
355
+
356
+ #start(slotIndex, job) {
357
+ const slot = this.#slots[slotIndex];
358
+ slot.job = job;
359
+ job.slot = slotIndex;
360
+ job.startedAt = performance.now();
361
+ job.engineIndex = slotIndex;
362
+ this.#emit();
363
+
364
+ (async () => {
365
+ try {
366
+ if (job.kind === JOB_KIND.EMBEDDING) {
367
+ // One shot, no stream: an embedding is a single forward pass and
368
+ // WebLLM returns the whole OpenAI-shaped response at once. There is
369
+ // no decode loop here, so none of the streaming bookkeeping applies —
370
+ // but the queueing, priority and preemption above all still did.
371
+ const res = await slot.engine.embeddings.create({ ...job.params });
372
+ job.usage = res.usage;
373
+ job.embeddings = res.data;
374
+ this.#finish(job, { cancelled: Boolean(job.cancelling && !job.preempting) });
375
+ return;
376
+ }
377
+
378
+ const stream = await slot.engine.chat.completions.create({
379
+ ...job.params,
380
+ stream: true,
381
+ stream_options: { include_usage: true },
382
+ });
383
+ for await (const chunk of stream) {
384
+ const choice = chunk.choices?.[0];
385
+ job.text += choice?.delta?.content ?? "";
386
+ // WebLLM's own "stop" | "length" | "abort" | "tool_calls". Kept
387
+ // because a caller cannot otherwise tell a natural stop from a
388
+ // `max_tokens` truncation.
389
+ if (choice?.finish_reason) job.finishReason = choice.finish_reason;
390
+ // Assigned, not merged: WebLLM parses the whole output message at the
391
+ // end and emits tool calls complete in one terminal chunk. It never
392
+ // streams the OpenAI-style fragments, so there is nothing to
393
+ // accumulate and a merge step would be machinery for a wire shape
394
+ // that is never produced.
395
+ if (choice?.delta?.tool_calls) job.toolCalls = choice.delta.tool_calls;
396
+ if (chunk.usage) job.usage = chunk.usage;
397
+ // The chunk goes on verbatim. It is already a compliant OpenAI
398
+ // envelope carrying id / created / model / logprobs /
399
+ // system_fingerprint; rebuilding one downstream only loses fields.
400
+ job.onChunk(chunk);
401
+ }
402
+ this.#finish(job, {
403
+ cancelled: Boolean(job.cancelling && !job.preempting),
404
+ preempted: Boolean(job.preempting),
405
+ });
406
+ } catch (err) {
407
+ this.#finish(job, { error: String(err?.message ?? err) });
408
+ } finally {
409
+ slot.job = null;
410
+ job.slot = null;
411
+ this.#pump();
412
+ this.#emit();
413
+ }
414
+ })();
415
+ }
416
+
417
+ #finish(job, extra) {
418
+ if (job.done) return;
419
+ job.done = true;
420
+ if (this.#bySession.get(job.session) === job) this.#bySession.delete(job.session);
421
+ job.resolve({
422
+ id: job.id,
423
+ text: job.text,
424
+ usage: job.usage,
425
+ ...(job.embeddings ? { embeddings: job.embeddings } : {}),
426
+ ...(job.toolCalls ? { toolCalls: job.toolCalls } : {}),
427
+ // Interrupted work reports "abort" whatever the stream last said.
428
+ finishReason: extra.cancelled || extra.preempted ? "abort" : job.finishReason,
429
+ engineIndex: job.engineIndex,
430
+ startedAt: job.startedAt,
431
+ finishedAt: performance.now(),
432
+ ...extra,
433
+ });
434
+ }
435
+
436
+ #emit() {
437
+ this.#onStateChange(this.status());
438
+ }
439
+ }
src/engine/prefetch.js ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `prefetch(modelId)` — fill the cache without building an engine.
3
+ *
4
+ * The gap this closes: WebLLM only ever downloads a model as a side effect of
5
+ * `reload()`, which instantiates the wasm and needs a GPU before it will fetch
6
+ * a single weight shard. So "warm the cache during onboarding, decide about the
7
+ * GPU later" is not expressible — and it is the thing an app wants to do while
8
+ * the user is still reading the welcome screen.
9
+ *
10
+ * We already write these exact caches for injected models (`ingest.js`); this is
11
+ * the same write plan with the bytes arriving over the network instead of off
12
+ * disk.
13
+ *
14
+ * ## The one dangerous part, and how it is closed
15
+ *
16
+ * To fetch the artifacts ourselves we must know their URLs, which means
17
+ * applying HuggingFace's `/resolve/main/` rule — the same rule
18
+ * [ARCHIVE.md](../../ARCHIVE.md) records *removing* a copy of, and that §2a of
19
+ * the roadmap says not to derive at registration time.
20
+ *
21
+ * Those decisions still hold and this does not contradict them: they are about
22
+ * not deriving a URL that WebLLM will derive again at load, which double-applies
23
+ * it. Here WebLLM is not in the loop at all — we are the loader — so there is
24
+ * nothing to double-apply.
25
+ *
26
+ * What makes it *safe* is that we do not trust our own derivation. A key that is
27
+ * off by one character writes a cache WebLLM's loader will never look in, and
28
+ * the symptom is the worst kind: prefetch reports success and the user downloads
29
+ * the model twice. So every prefetch ends by asking **WebLLM's own
30
+ * `hasModelInCache`** — which derives the URL through the very function we are
31
+ * mirroring — whether the model is really there. If it says no, this throws
32
+ * instead of claiming success.
33
+ *
34
+ * `webllm-contract.test.mjs` additionally pins the rule against the bundle, so
35
+ * an upstream change to the URL scheme fails a test rather than a user's
36
+ * download.
37
+ */
38
+ import { ERROR, EngineError } from "./errors.js";
39
+ import {
40
+ CHAT_CONFIG,
41
+ CONTENT_TYPES,
42
+ LEGACY_TENSOR_MANIFEST,
43
+ TENSOR_MANIFEST,
44
+ } from "./ingest.js";
45
+ import { CACHE_CONFIG, CACHE_MODEL, CACHE_WASM, isInjected } from "./model-store.js";
46
+
47
+ /**
48
+ * WebLLM's `cleanModelUrl`, mirrored deliberately.
49
+ *
50
+ * Kept character-for-character with the bundle's version (see the contract
51
+ * test) because the whole point is to produce the same cache keys its loader
52
+ * will look for.
53
+ */
54
+ export function resolveModelUrl(modelUrl) {
55
+ let url = modelUrl + (modelUrl.endsWith("/") ? "" : "/");
56
+ if (!url.match(/.+\/resolve\/.+\//)) url += "resolve/main/";
57
+ return new URL(url).href;
58
+ }
59
+
60
+ /**
61
+ * @param {object} opts
62
+ * @param {string} opts.modelId
63
+ * @param {object} opts.record the merged app-config entry: `model`, `model_lib`
64
+ * @param {(p: {phase: string, done: number, total: number, label: string}) => void} [opts.onProgress]
65
+ * @param {AbortSignal} [opts.signal]
66
+ * @param {(url: string, init?: object) => Promise<Response>} [opts.fetchImpl]
67
+ * @returns {Promise<{modelId: string, files: number, bytes: number, alreadyCached: boolean}>}
68
+ */
69
+ export async function prefetchModel({
70
+ modelId,
71
+ record,
72
+ onProgress = () => {},
73
+ signal,
74
+ fetchImpl = globalThis.fetch,
75
+ }) {
76
+ if (!record?.model_lib) {
77
+ throw new EngineError(
78
+ ERROR.BAD_REQUEST,
79
+ `"${modelId}" has no \`model_lib\`, so there is nothing to prefetch from. ` +
80
+ "A remote source needs one; see load(url, { modelLib }).",
81
+ { modelId },
82
+ );
83
+ }
84
+
85
+ const base = resolveModelUrl(record.model);
86
+ const get = async (url, what) => {
87
+ if (signal?.aborted) throw aborted(modelId);
88
+ const res = await fetchImpl(url, signal ? { signal } : undefined).catch((err) => {
89
+ throw new EngineError(ERROR.GENERATION_FAILED, `Prefetch could not reach ${what}: ${err?.message ?? err}`, {
90
+ modelId,
91
+ url,
92
+ });
93
+ });
94
+ if (!res.ok) {
95
+ throw new EngineError(
96
+ ERROR.UNKNOWN_MODEL,
97
+ `Prefetch got ${res.status} for ${what} at ${url}. ` +
98
+ "Check the model's base URL — a 404 here usually means the id or the URL is wrong.",
99
+ { modelId, url, status: res.status },
100
+ );
101
+ }
102
+ return res;
103
+ };
104
+
105
+ // The config first: it names the tokenizer files, so the plan cannot be built
106
+ // without it. Same order `reload()` uses.
107
+ onProgress({ phase: "manifest", done: 0, total: 1, label: CHAT_CONFIG });
108
+ const configRes = await get(base + CHAT_CONFIG, CHAT_CONFIG);
109
+ const configBytes = await configRes.arrayBuffer();
110
+ const chatConfig = parseJson(configBytes, CHAT_CONFIG, modelId);
111
+
112
+ // `tensor-cache.json`, falling back to the legacy name, exactly as ingest does.
113
+ let manifestName = TENSOR_MANIFEST;
114
+ let manifestRes = await fetchImpl(base + TENSOR_MANIFEST, signal ? { signal } : undefined).catch(() => null);
115
+ if (!manifestRes?.ok) {
116
+ manifestName = LEGACY_TENSOR_MANIFEST;
117
+ manifestRes = await get(base + LEGACY_TENSOR_MANIFEST, "the weight index");
118
+ }
119
+ const manifestBytes = await manifestRes.arrayBuffer();
120
+ const manifest = parseJson(manifestBytes, manifestName, modelId);
121
+
122
+ const shards = (manifest.records ?? []).map((r) => r.dataPath).filter(Boolean);
123
+ if (shards.length === 0) {
124
+ throw new EngineError(
125
+ ERROR.UNKNOWN_MODEL,
126
+ `${manifestName} at ${base} lists no weight shards, so this is not an MLC model directory.`,
127
+ { modelId, url: base + manifestName },
128
+ );
129
+ }
130
+
131
+ const tokenizers = (Array.isArray(chatConfig.tokenizer_files) ? chatConfig.tokenizer_files : []).filter(
132
+ (n) => n === "tokenizer.json" || n === "tokenizer.model",
133
+ );
134
+
135
+ const plan = [
136
+ { scope: CACHE_CONFIG, url: base + CHAT_CONFIG, body: configBytes, type: CONTENT_TYPES.json },
137
+ { scope: CACHE_MODEL, url: base + manifestName, body: manifestBytes, type: CONTENT_TYPES.json },
138
+ ...tokenizers.map((name) => ({
139
+ scope: CACHE_MODEL,
140
+ url: base + name,
141
+ type: name.endsWith(".json") ? CONTENT_TYPES.json : CONTENT_TYPES.bin,
142
+ })),
143
+ ...shards.map((p) => ({ scope: CACHE_MODEL, url: new URL(p, base).href, type: CONTENT_TYPES.bin })),
144
+ // Verbatim, never derived — `model_lib` is a literal URL on the record and
145
+ // is not even on the same origin as the weights for any prebuilt model.
146
+ { scope: CACHE_WASM, url: record.model_lib, type: CONTENT_TYPES.wasm },
147
+ ];
148
+
149
+ const openCaches = new Map();
150
+ const cacheFor = async (scope) => {
151
+ if (!openCaches.has(scope)) openCaches.set(scope, await caches.open(scope));
152
+ return openCaches.get(scope);
153
+ };
154
+
155
+ let bytes = 0;
156
+ let done = 0;
157
+ for (const item of plan) {
158
+ if (signal?.aborted) throw aborted(modelId);
159
+ const cache = await cacheFor(item.scope);
160
+ onProgress({ phase: "downloading", done, total: plan.length, label: basename(item.url) });
161
+
162
+ // Skip what is already there: a resumed prefetch should cost only the
163
+ // remainder, the same way a resumed `load()` does.
164
+ if (item.body === undefined && (await cache.match(new Request(item.url)))) {
165
+ done += 1;
166
+ continue;
167
+ }
168
+
169
+ const body = item.body ?? (await (await get(item.url, basename(item.url))).arrayBuffer());
170
+ bytes += body.byteLength;
171
+ await cache.put(
172
+ new Request(item.url),
173
+ new Response(body, { status: 200, headers: { "Content-Type": item.type } }),
174
+ );
175
+ done += 1;
176
+ }
177
+ onProgress({ phase: "downloading", done, total: plan.length, label: "done" });
178
+
179
+ return { modelId, files: plan.length, bytes, alreadyCached: false };
180
+ }
181
+
182
+ const aborted = (modelId) =>
183
+ new EngineError(ERROR.ABORTED, `Prefetch of "${modelId}" was aborted.`, { modelId });
184
+
185
+ function parseJson(buffer, what, modelId) {
186
+ try {
187
+ return JSON.parse(new TextDecoder().decode(buffer));
188
+ } catch {
189
+ throw new EngineError(
190
+ ERROR.UNKNOWN_MODEL,
191
+ `${what} for "${modelId}" is not valid JSON — the URL is probably not an MLC model directory.`,
192
+ { modelId, what },
193
+ );
194
+ }
195
+ }
196
+
197
+ const basename = (url) => url.split("/").pop() || url;
198
+
199
+ /** Only for injected models: they are in the cache before they are ever registered. */
200
+ export const isAlreadyLocal = (record) => isInjected(record);
src/engine/recipes.js ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The three shapes of work, as one call each.
3
+ *
4
+ * `complete()` and `chat.completions.create()` can already express all of this.
5
+ * What they cannot do is stop a caller getting the *scheduling* wrong, and the
6
+ * scheduling is the part that is easy to get wrong and invisible when you do:
7
+ * ghost text that lags a keystroke behind, a conversation whose turns fight
8
+ * each other for engines, a one-shot that superseded the last one because it
9
+ * reused a session key. Those are the bugs AI.md's "Getting these wrong" table
10
+ * is made of, and every row of it is a scheduling mistake rather than a
11
+ * generation one.
12
+ *
13
+ * So these are not wrappers that save typing. Each one is a *policy*:
14
+ *
15
+ * ask() one-shot, its own task, no session, nothing to supersede
16
+ * conversation() one stable task for every turn, history the caller can see
17
+ * ghostText() debounce + one session key + interactive + drop-if-stale
18
+ *
19
+ * ## What they deliberately do not do
20
+ *
21
+ * **They author no prompts.** `ask()` and `conversation()` carry the caller's
22
+ * own text through as a message; `ghostText()` *requires* a `prompt` function
23
+ * and has no default for it. This is AI.md's load-bearing rule, and the reason
24
+ * is not purity: prompts are model-specific, and switching this project's own
25
+ * build from `Qwen3.5-0.8B` to `Qwen3.8-2B-Distill` changed the conversation
26
+ * template and made every reply open with a `<think>` block. A prompt that
27
+ * lives in the caller survives that. One baked in here would have to be
28
+ * rewritten and re-shipped to every caller.
29
+ */
30
+ import { ERROR, EngineError } from "./errors.js";
31
+ import { PRIORITY } from "./constants.js";
32
+
33
+ /**
34
+ * A single question, with nothing kept afterwards.
35
+ *
36
+ * Its own task and no session, so two `ask()`s in flight never supersede each
37
+ * other and never queue behind one another for the same engine — which is what
38
+ * would happen if this shared a session key with anything else.
39
+ *
40
+ * @param {import("./engine.js").ScheduledEngine} engine
41
+ * @param {string | Array<{role: string, content: string}>} input
42
+ * @param {object} [opts] anything `complete()` takes; `onDelta` to stream
43
+ * @returns {Promise<string>} the reply text
44
+ */
45
+ export async function ask(engine, input, { onDelta, ...opts } = {}) {
46
+ const messages = toMessages(input, "ask");
47
+ const { text } = await engine.complete(
48
+ { priority: PRIORITY.NORMAL, ...opts, messages },
49
+ onDelta,
50
+ );
51
+ return text;
52
+ }
53
+
54
+ /**
55
+ * A multi-turn conversation that remembers its own history.
56
+ *
57
+ * Two things this gets right that hand-rolled history usually does not:
58
+ *
59
+ * **One task for the whole conversation.** Every turn carries the same `task`,
60
+ * so a conversation holds at most one engine and a long reply can never occupy
61
+ * the pool while another conversation waits. Turns within it are serialised,
62
+ * which is what a conversation means anyway.
63
+ *
64
+ * **A bounded history, by default.** There is no cross-turn KV reuse on this
65
+ * stack (AI.md: every turn re-prefills the whole history at ~5.27 ms/token), so
66
+ * an unbounded conversation gets quadratically slower and a turn near the 4096
67
+ * limit waits ~22 s for its first token. `keep` bounds it. Set `keep: Infinity`
68
+ * to opt out, having read that sentence.
69
+ *
70
+ * @param {import("./engine.js").ScheduledEngine} engine
71
+ * @param {object} [opts]
72
+ * @param {string} [opts.system] a system message, prepended and never trimmed
73
+ * @param {number} [opts.keep] how many *exchanges* of history to carry
74
+ * @param {string} [opts.task] defaults to a fresh id
75
+ */
76
+ export function conversation(engine, { system, keep = 12, task, ...defaults } = {}) {
77
+ if (typeof keep !== "number" || (keep < 1 && keep !== Infinity)) {
78
+ throw new EngineError(
79
+ ERROR.BAD_REQUEST,
80
+ `conversation({ keep }) must be a positive number of exchanges, or Infinity. Got ${JSON.stringify(keep)}.`,
81
+ { keep },
82
+ );
83
+ }
84
+
85
+ const id = task ?? `conversation-${++counter}`;
86
+ /** User/assistant messages only; `system` is held apart so trimming cannot eat it. */
87
+ let turns = [];
88
+ /** Turns are serialised: overlapping ones would interleave history. */
89
+ let inFlight = Promise.resolve();
90
+
91
+ async function turn(content, onDelta) {
92
+ const sent = [...api.messages, { role: "user", content }];
93
+ const { text, finishReason } = await engine.complete(
94
+ { ...defaults, task: id, priority: defaults.priority ?? PRIORITY.NORMAL, messages: sent },
95
+ onDelta,
96
+ );
97
+ // Appended only on success: a failed turn must not leave the history
98
+ // holding a question the model never answered.
99
+ turns.push({ role: "user", content }, { role: "assistant", content: text });
100
+ trim();
101
+ return { text, finishReason };
102
+ }
103
+
104
+ const api = {
105
+ /** The messages as they would be sent, including the system message. */
106
+ get messages() {
107
+ return system ? [{ role: "system", content: system }, ...turns] : [...turns];
108
+ },
109
+
110
+ /** Exchanges currently retained. */
111
+ get length() {
112
+ return Math.ceil(turns.length / 2);
113
+ },
114
+
115
+ /**
116
+ * Say something and get the reply, with the exchange appended to history.
117
+ *
118
+ * Serialised against the previous turn: a conversation whose turns
119
+ * overlapped would interleave history and produce replies to the wrong
120
+ * question.
121
+ *
122
+ * @param {string} content
123
+ * @param {(delta: string) => void} [onDelta]
124
+ */
125
+ async say(content, onDelta) {
126
+ if (typeof content !== "string" || content.length === 0) {
127
+ throw new EngineError(ERROR.BAD_REQUEST, "conversation.say() needs a non-empty string.", {
128
+ received: typeof content,
129
+ });
130
+ }
131
+ const run = inFlight.then(() => turn(content, onDelta));
132
+ // The chain must survive a failed turn: `.catch` here keeps the *queue*
133
+ // moving without swallowing the rejection the caller is awaiting.
134
+ inFlight = run.catch(() => {});
135
+ return run;
136
+ },
137
+
138
+ /** Forget the history. The system message and settings survive. */
139
+ reset() {
140
+ turns = [];
141
+ return api;
142
+ },
143
+
144
+ /** Drop the history in, e.g. when restoring a saved conversation. */
145
+ restore(messages) {
146
+ turns = messages.filter((m) => m.role !== "system").map((m) => ({ ...m }));
147
+ trim();
148
+ return api;
149
+ },
150
+ };
151
+
152
+ function trim() {
153
+ if (keep === Infinity) return;
154
+ const max = keep * 2;
155
+ if (turns.length > max) turns = turns.slice(turns.length - max);
156
+ }
157
+
158
+ return api;
159
+ }
160
+
161
+ /**
162
+ * Ghost text: the scheduling discipline, with the prompt left to the caller.
163
+ *
164
+ * Every part of this exists because of a specific way ghost text goes wrong:
165
+ *
166
+ * | | |
167
+ * | --- | --- |
168
+ * | one stable `session` | a fresh id per keystroke makes every stale request still run — AI.md's first "getting these wrong" row |
169
+ * | `interactive` priority | it is the one band that may preempt work that opted in |
170
+ * | debounce | a request per keystroke queues faster than the GPU drains |
171
+ * | short `max_tokens` | ghost text is a few words; paying for more is pure latency |
172
+ * | resolves `null` when stale | so a superseded suggestion **cannot** be rendered by mistake |
173
+ *
174
+ * That last one is the difference between this and a wrapper. The engine
175
+ * already supersedes stale requests; what a caller still has to remember is not
176
+ * to paint the answer that comes back. Returning `null` removes the choice.
177
+ *
178
+ * @param {import("./engine.js").ScheduledEngine} engine
179
+ * @param {object} opts
180
+ * @param {(context: any) => string | Array<object>} opts.prompt **required** —
181
+ * builds the messages. Never defaulted: see the module header.
182
+ * @param {number} [opts.debounceMs]
183
+ * @param {number} [opts.maxTokens]
184
+ * @param {string} [opts.session]
185
+ */
186
+ export function ghostText(engine, { prompt, debounceMs = 120, maxTokens = 24, session, ...defaults } = {}) {
187
+ if (typeof prompt !== "function") {
188
+ throw new EngineError(
189
+ ERROR.BAD_REQUEST,
190
+ "ghostText({ prompt }) needs a function that turns your editor context into messages. " +
191
+ "It has no default on purpose: prompts are model-specific and belong to whoever owns the feature.",
192
+ { received: typeof prompt },
193
+ );
194
+ }
195
+
196
+ const key = session ?? `ghost-${++counter}`;
197
+ let generation = 0;
198
+ let timer = null;
199
+ /**
200
+ * Settles the debounce wait of the call currently holding the timer.
201
+ *
202
+ * Without this, cancelling that timer left its `await` with nothing to
203
+ * resolve it — every superseded keystroke leaked a promise that never
204
+ * settled, and `Promise.all` over a burst of them hung forever. A superseded
205
+ * waiter has to be *woken and told it lost*, not merely disarmed.
206
+ */
207
+ let wake = null;
208
+
209
+ const stopWaiting = (quiet) => {
210
+ clearTimeout(timer);
211
+ timer = null;
212
+ const settle = wake;
213
+ wake = null;
214
+ settle?.(quiet);
215
+ };
216
+
217
+ const api = {
218
+ /**
219
+ * Ask for a suggestion. Debounced, superseding, and `null` when stale.
220
+ *
221
+ * @param {any} context whatever `prompt` takes
222
+ * @returns {Promise<string | null>} `null` if superseded or cancelled
223
+ */
224
+ async suggest(context) {
225
+ const mine = ++generation;
226
+
227
+ if (debounceMs > 0) {
228
+ stopWaiting(false); // the previous waiter loses, and is told so
229
+ const quiet = await new Promise((resolve) => {
230
+ wake = resolve;
231
+ timer = setTimeout(() => stopWaiting(true), debounceMs);
232
+ });
233
+ // A newer keystroke landed while waiting; that request owns the session.
234
+ if (!quiet || mine !== generation) return null;
235
+ }
236
+
237
+ const messages = toMessages(prompt(context), "ghostText's prompt()");
238
+ const result = await engine.complete({
239
+ max_tokens: maxTokens,
240
+ ...defaults,
241
+ messages,
242
+ session: key,
243
+ priority: PRIORITY.INTERACTIVE,
244
+ });
245
+
246
+ // Two ways to be stale, and both must return null: the engine superseded
247
+ // us (`cancelled`), or a newer suggest() started while we generated.
248
+ if (result.cancelled || result.preempted || mine !== generation) return null;
249
+ return result.text;
250
+ },
251
+
252
+ /** On blur, or on accept. Cancels in flight and invalidates anything pending. */
253
+ cancel() {
254
+ generation += 1;
255
+ stopWaiting(false);
256
+ return engine.cancel(key);
257
+ },
258
+ };
259
+
260
+ return api;
261
+ }
262
+
263
+ let counter = 0;
264
+
265
+ /** The one place caller text becomes a message, so no verb invents its own shape. */
266
+ function toMessages(input, who) {
267
+ if (typeof input === "string") {
268
+ if (input.length === 0) {
269
+ throw new EngineError(ERROR.BAD_REQUEST, `${who} was given an empty string.`, { who });
270
+ }
271
+ return [{ role: "user", content: input }];
272
+ }
273
+ if (Array.isArray(input) && input.length > 0 && input.every((m) => m?.role && m?.content !== undefined)) {
274
+ return input;
275
+ }
276
+ throw new EngineError(
277
+ ERROR.BAD_REQUEST,
278
+ `${who} needs a string, or a non-empty array of { role, content } messages.`,
279
+ { who, received: Array.isArray(input) ? "array" : typeof input },
280
+ );
281
+ }
src/engine/sources.js ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * What `load()` was handed, decided before anything touches the network.
3
+ *
4
+ * `load` absorbs `registerModel` and `ingestModelFolder`, so one call has to
5
+ * tell four things apart: a model id, a URL to fetch from, an explicit remote
6
+ * spec, and a folder off disk. Keeping that decision here — pure, synchronous,
7
+ * no store and no engine — means it can be tested exhaustively without a GPU,
8
+ * and means `load()` reads as "classify, then act".
9
+ *
10
+ * Two rules this deliberately does **not** implement, both measured rather than
11
+ * assumed:
12
+ *
13
+ * - **`modelLib` is never guessed.** `<base><id>-webgpu.wasm` matches 0 of the
14
+ * 163 prebuilt models (real names carry a `_cs1k`-style suffix and drop the
15
+ * `-MLC`), and 0 of 163 host the lib on the same origin as the weights —
16
+ * they live on `raw.githubusercontent.com`. A guess would be wrong every
17
+ * time and would surface as a 404 deep inside WebLLM's loader, which is the
18
+ * worst place to learn it. So a URL source without `modelLib` fails here,
19
+ * immediately, with a sentence saying why.
20
+ * - **`/resolve/main/` is never derived for HuggingFace URLs.** WebLLM's
21
+ * `cleanModelUrl` already appends it when the URL does not match
22
+ * `.+/resolve/.+/`. Deriving it here would re-introduce exactly the
23
+ * duplication ARCHIVE.md records removing. The URL is passed through.
24
+ */
25
+ import { ERROR, EngineError } from "./errors.js";
26
+
27
+ /** @typedef {"id"|"remote"|"register"|"files"} SourceKind */
28
+
29
+ export const SOURCE_KIND = {
30
+ ID: "id",
31
+ REMOTE: "remote",
32
+ REGISTER: "register",
33
+ FILES: "files",
34
+ };
35
+
36
+ /** A scheme, as `absolutize()` recognises one. */
37
+ const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
38
+
39
+ /**
40
+ * A string is a location rather than an id when it carries a scheme or is
41
+ * explicitly path-shaped. No prebuilt model id contains `/` or `:`, so this
42
+ * never steals a real id — and it means `load("/models/foo/")` is understood
43
+ * as the URL it obviously is instead of being looked up and reported missing.
44
+ */
45
+ export const looksLikeUrl = (s) =>
46
+ HAS_SCHEME.test(s) || s.startsWith("/") || s.startsWith("./") || s.startsWith("../");
47
+
48
+ /** Duck-typed: `DataTransfer` and `FileList` do not exist in Node. */
49
+ export const isDataTransfer = (v) => !!v && typeof v === "object" && "items" in v && "files" in v;
50
+ export const isFileList = (v) =>
51
+ !!v &&
52
+ typeof v === "object" &&
53
+ typeof v.length === "number" &&
54
+ !Array.isArray(v) &&
55
+ (v.length === 0 || typeof v[0]?.name === "string");
56
+
57
+ /**
58
+ * The local label for a model loaded from a URL.
59
+ *
60
+ * Safe to derive, unlike `modelLib`: an id is a key in our own registry, never
61
+ * a path anything fetches, so a wrong guess is visible immediately and costs
62
+ * nothing. `.../mlc-ai/Foo-MLC` and `.../models/foo/` both give the last real
63
+ * segment.
64
+ */
65
+ export function idFromUrl(url) {
66
+ const path = HAS_SCHEME.test(url) ? safeUrlPath(url) : url;
67
+ const segments = path.split("/").filter((s) => s && s !== "." && s !== "..");
68
+ const last = segments.at(-1);
69
+ return last ? decodeURIComponent(last) : null;
70
+ }
71
+
72
+ function safeUrlPath(url) {
73
+ try {
74
+ return new URL(url).pathname;
75
+ } catch {
76
+ return url;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * @param {string | object} src
82
+ * @param {{id?: string, modelLib?: string}} [opts]
83
+ * @returns {{kind: SourceKind, modelId?: string, model?: string, modelLib?: string, files?: unknown}}
84
+ */
85
+ export function classifySource(src, opts = {}) {
86
+ if (src === null || src === undefined || src === "") {
87
+ throw new EngineError(
88
+ ERROR.BAD_REQUEST,
89
+ "load() needs a model id, a URL, `{ model, modelLib }`, or a folder " +
90
+ "(`{ files }`, a FileList, or a drop event's DataTransfer).",
91
+ );
92
+ }
93
+
94
+ if (typeof src === "string") {
95
+ if (!looksLikeUrl(src)) return { kind: SOURCE_KIND.ID, modelId: src };
96
+ return remote(src, opts.modelLib, opts.id ?? idFromUrl(src));
97
+ }
98
+
99
+ // Folder routes first: a DataTransfer also has other properties, and `files`
100
+ // is the field that decides, exactly as `registerModel` already reads it.
101
+ if (isDataTransfer(src) || isFileList(src) || Array.isArray(src)) {
102
+ return { kind: SOURCE_KIND.FILES, files: src, modelId: opts.id };
103
+ }
104
+
105
+ if (typeof src === "object") {
106
+ if (src.files !== undefined) {
107
+ if (src.model || src.modelLib) {
108
+ throw new EngineError(
109
+ ERROR.BAD_REQUEST,
110
+ "load() takes either `files` (local, never fetched) or `model`/`modelLib` " +
111
+ "(a base URL to fetch), not both.",
112
+ );
113
+ }
114
+ return { kind: SOURCE_KIND.FILES, files: src.files, modelId: src.modelId ?? opts.id };
115
+ }
116
+ if (src.model !== undefined) {
117
+ // The id falls back to the URL exactly as the string form's does — the
118
+ // two spellings of "a model at this URL" must not disagree about it.
119
+ const spec = remote(
120
+ src.model,
121
+ src.modelLib ?? opts.modelLib,
122
+ src.modelId ?? opts.id ?? idFromUrl(String(src.model)),
123
+ );
124
+ return { ...spec, kind: SOURCE_KIND.REGISTER };
125
+ }
126
+ }
127
+
128
+ throw new EngineError(
129
+ ERROR.BAD_REQUEST,
130
+ "load() did not recognise that source. Pass a model id, a URL, " +
131
+ "`{ model, modelLib }`, or a folder (`{ files }`, a FileList, or a DataTransfer).",
132
+ { received: typeof src },
133
+ );
134
+ }
135
+
136
+ /** The one place a URL source is validated, so both string and object forms agree. */
137
+ function remote(model, modelLib, modelId) {
138
+ if (!modelLib) {
139
+ throw new EngineError(
140
+ ERROR.BAD_REQUEST,
141
+ `Loading "${model}" from a URL needs \`modelLib\` — the compiled .wasm — and it cannot be ` +
142
+ "guessed: across all 163 prebuilt models, none has a lib name derivable from its id and " +
143
+ "none hosts the lib on the same origin as the weights. " +
144
+ "Pass load(url, { modelLib: \"https://.../foo-webgpu.wasm\" }).",
145
+ { model },
146
+ );
147
+ }
148
+ if (!modelId) {
149
+ throw new EngineError(
150
+ ERROR.BAD_REQUEST,
151
+ `Could not derive a model id from "${model}". Pass one as load(src, { id }).`,
152
+ { model },
153
+ );
154
+ }
155
+ return { kind: SOURCE_KIND.REMOTE, model, modelLib, modelId };
156
+ }
157
+
158
+ /**
159
+ * Ids close enough to be a typo, so an unknown id can say "did you mean".
160
+ *
161
+ * Cheap on purpose — case-insensitive substring both ways, then a bounded edit
162
+ * distance. The list is at most a few hundred entries and this runs once, on a
163
+ * path that is already about to throw.
164
+ */
165
+ export function nearMatches(wanted, available, limit = 3) {
166
+ const needle = wanted.toLowerCase();
167
+ const scored = available
168
+ .map((id) => {
169
+ const hay = id.toLowerCase();
170
+ if (hay === needle) return { id, score: 0 };
171
+ if (hay.includes(needle) || needle.includes(hay)) return { id, score: 1 };
172
+ return { id, score: 2 + editDistance(needle, hay) };
173
+ })
174
+ // Beyond this a "suggestion" is noise, and a wrong suggestion is worse than
175
+ // none — it sends the reader looking for a model that was never the point.
176
+ .filter(({ score, id }) => score <= 2 + Math.ceil(Math.max(id.length, needle.length) / 3))
177
+ .sort((a, b) => a.score - b.score || a.id.localeCompare(b.id));
178
+
179
+ return scored.slice(0, limit).map(({ id }) => id);
180
+ }
181
+
182
+ /** Levenshtein, two rows. */
183
+ function editDistance(a, b) {
184
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
185
+ for (let i = 1; i <= a.length; i++) {
186
+ const row = [i];
187
+ for (let j = 1; j <= b.length; j++) {
188
+ row[j] = Math.min(
189
+ prev[j] + 1,
190
+ row[j - 1] + 1,
191
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
192
+ );
193
+ }
194
+ prev = row;
195
+ }
196
+ return prev[b.length];
197
+ }
src/manager/manager.css ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .wrap { max-width: 820px; margin: 0 auto; padding: 28px 24px 72px; display: flex; flex-direction: column; gap: 30px; }
2
+
3
+ header { padding-bottom: 14px; border-bottom: 1px solid var(--line); }
4
+ section { display: flex; flex-direction: column; gap: 10px; }
5
+
6
+ /* ------------------------------------------------------------ engine API --- */
7
+ /* First section on the page: an integrator opening this page is here for the
8
+ ID and a snippet, not to re-read the model list. */
9
+ #api { padding: 18px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); }
10
+
11
+ .idbar { display: flex; gap: 8px; align-items: center; }
12
+ .idbar > code {
13
+ padding: 7px 12px;
14
+ border: 1px solid var(--line);
15
+ border-radius: 8px;
16
+ background: var(--bg);
17
+ overflow-x: auto;
18
+ white-space: nowrap;
19
+ }
20
+
21
+ .tabs { display: flex; gap: 4px; margin-top: 4px; }
22
+ .tab { border-color: transparent; color: var(--muted); padding: 5px 11px; border-radius: 99px; }
23
+ .tab:hover:not(.active) { background: var(--panel-strong); }
24
+ .tab.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 500; }
25
+
26
+ .codewrap { position: relative; }
27
+ .codewrap .copy {
28
+ position: absolute;
29
+ top: 8px;
30
+ right: 8px;
31
+ padding: 4px 10px;
32
+ font-size: 12px;
33
+ background: var(--bg);
34
+ opacity: 0;
35
+ transition: opacity .12s ease;
36
+ }
37
+ .codewrap:hover .copy, .codewrap .copy:focus-visible { opacity: 1; }
38
+
39
+ /* ---------------------------------------------------------------- models --- */
40
+ #drop {
41
+ border: 1.5px dashed var(--line-strong);
42
+ border-radius: 12px;
43
+ padding: 28px 20px;
44
+ text-align: center;
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 10px;
48
+ align-items: center;
49
+ transition: border-color .15s, background .15s;
50
+ }
51
+ #drop.over { border-color: var(--accent); background: var(--panel-strong); }
52
+ #drop p { margin: 0; max-width: 560px; }
53
+
54
+ table { width: 100%; border-collapse: collapse; }
55
+ th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
56
+ th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
57
+ td.mono { font-size: 12px; }
58
+ table.flags td { border: none; padding: 3px 12px 3px 0; }
59
+
60
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
61
+ label { display: flex; flex-direction: column; gap: 5px; color: var(--muted); font-size: 12px; }
62
+ label.block { width: 100%; }
63
+ label input, label textarea { color: var(--ink); background: var(--panel); }
64
+ textarea { resize: vertical; }
65
+
66
+ pre {
67
+ background: var(--bg);
68
+ border: 1px solid var(--line);
69
+ border-radius: 10px;
70
+ padding: 14px;
71
+ overflow-x: auto;
72
+ margin: 0;
73
+ line-height: 1.55;
74
+ }
75
+ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
76
+
77
+ .pill { padding: 2px 8px; border-radius: 99px; font-size: 10px; border: 1px solid var(--line-strong); color: var(--muted); }
78
+ .pill.ok { color: var(--accent); border-color: var(--accent); }
79
+ .pill.bad { color: var(--ink); border-color: var(--ink); }
80
+
81
+ /* There is no fourth colour to warn in, so a warning is stated in the accent
82
+ and carried by the sentence rather than by hue alone. */
83
+ .warn { color: var(--accent); }
src/manager/manager.html ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Everything WebGPU — Models</title>
6
+ <link rel="stylesheet" href="../ui.css" />
7
+ <link rel="stylesheet" href="manager.css" />
8
+ </head>
9
+ <body>
10
+ <div class="wrap">
11
+
12
+ <header class="row">
13
+ <span id="dot" class="dot"></span>
14
+ <h1 class="grow">Everything WebGPU</h1>
15
+ <span id="engineStatus" class="muted mono"></span>
16
+ <div class="bar" id="engineBar" hidden><i id="engineProgress"></i></div>
17
+ <div id="engineHint" class="muted hint" hidden></div>
18
+ </header>
19
+
20
+ <section id="gpu" class="banner" hidden></section>
21
+
22
+ <section id="api">
23
+ <h2>Engine API</h2>
24
+ <div class="idbar">
25
+ <code id="selfId" class="mono grow"></code>
26
+ <button id="copyId">Copy ID</button>
27
+ </div>
28
+ <p class="muted">
29
+ Any Firefox extension drives this engine by messaging that ID — <code>runtime.sendMessage</code> for
30
+ request/response, <code>runtime.connect</code> for streaming. Send raw requests: the engine schedules a
31
+ shared GPU, it does not author prompts, so prompts stay on your side and survive a model change.
32
+ </p>
33
+
34
+ <div class="tabs" role="tablist">
35
+ <button class="tab" data-tab="completion" role="tab">Completion</button>
36
+ <button class="tab" data-tab="translation" role="tab">Translation</button>
37
+ <button class="tab" data-tab="reformat" role="tab">Reformat</button>
38
+ </div>
39
+ <p id="apiWhy" class="muted hint"></p>
40
+ <div class="codewrap">
41
+ <button id="copyCode" class="copy">Copy</button>
42
+ <pre class="mono"><code id="apiSample"></code></pre>
43
+ </div>
44
+ <p class="muted hint">
45
+ One task holds one engine, so a whole <code>batch</code> occupies one and can never starve a completion.
46
+ A second engine is built only when a second task is waiting, and on this model it is worth ~1.06x — it
47
+ buys isolation, not speed. Full reference in <code>AI.md</code>.
48
+ </p>
49
+ </section>
50
+
51
+ <section>
52
+ <h2>Add a model</h2>
53
+ <div id="drop" tabindex="0">
54
+ <strong>Drop a compiled MLC model folder here</strong>
55
+ <p class="muted">
56
+ Files are copied straight into this extension's Cache Storage — nothing is downloaded.
57
+ The folder needs <code>mlc-chat-config.json</code>, <code>tensor-cache.json</code>
58
+ (or <code>ndarray-cache.json</code>), every <code>params_shard_*.bin</code>,
59
+ <code>tokenizer.json</code>, and exactly one <code>*-webgpu.wasm</code> model library.
60
+ </p>
61
+ <button id="pick">Choose folder…</button>
62
+ <input id="picker" type="file" webkitdirectory directory multiple hidden />
63
+ </div>
64
+ <div id="ingest" hidden>
65
+ <div class="bar"><i id="ingestBar"></i></div>
66
+ <div id="ingestStatus" class="muted mono"></div>
67
+ </div>
68
+ <div id="ingestError" class="banner error" hidden></div>
69
+ </section>
70
+
71
+ <section>
72
+ <h2>Local models</h2>
73
+ <table id="models">
74
+ <thead>
75
+ <tr><th>Model</th><th>Size</th><th>Shards</th><th>Library</th><th>Cache</th><th></th></tr>
76
+ </thead>
77
+ <tbody></tbody>
78
+ </table>
79
+ <p id="noModels" class="muted" hidden>Nothing registered yet.</p>
80
+ <p class="muted mono">Storage in use: <span id="quota">—</span></p>
81
+ </section>
82
+
83
+ <section>
84
+ <h2>Generation defaults</h2>
85
+ <div class="grid">
86
+ <label>Temperature <input id="temperature" type="number" min="0" max="2" step="0.05" /></label>
87
+ <label>Max tokens <input id="maxTokens" type="number" min="1" max="32768" step="1" /></label>
88
+ <label>Engines in pool <input id="engineCount" type="number" min="1" max="4" step="1" /></label>
89
+ <label>Decode steps per sync <input id="decodeSteps" type="number" min="1" max="32" step="1" /></label>
90
+ </div>
91
+ <p id="poolCost" class="muted mono"></p>
92
+ <p id="decodeCost" class="muted mono"></p>
93
+ <label class="block">System prompt
94
+ <textarea id="systemPrompt" rows="3" placeholder="(none)"></textarea>
95
+ </label>
96
+ <label class="block">External extension allowlist
97
+ <input id="allowedExternalIds" type="text" placeholder="empty = allow every installed extension" />
98
+ <span class="muted mono">Comma-separated extension IDs permitted to call the engine API.</span>
99
+ </label>
100
+ <div class="row">
101
+ <button id="save" class="primary">Save settings</button>
102
+ <span id="saved" class="muted" hidden>Saved.</span>
103
+ </div>
104
+ </section>
105
+
106
+ <section>
107
+ <h2>Firefox setup (macOS)</h2>
108
+ <p class="muted">WebGPU/Metal must be enabled in <code>about:config</code> before a model can load:</p>
109
+ <table class="flags">
110
+ <tbody>
111
+ <tr><td class="mono">dom.webgpu.enabled</td><td class="mono">true</td><td class="muted">Exposes <code>navigator.gpu</code>.</td></tr>
112
+ <tr><td class="mono">gfx.webgpu.ignore-blocklist</td><td class="mono">true</td><td class="muted">Only if your Mac's GPU is blocklisted.</td></tr>
113
+ <tr><td class="mono">dom.webgpu.service-workers.enabled</td><td class="mono">true</td><td class="muted">Harmless; needed on builds that gate worker contexts.</td></tr>
114
+ </tbody>
115
+ </table>
116
+ <p class="muted">Restart Firefox after changing these. Nightly on Apple Silicon is the best-tested combination.</p>
117
+ </section>
118
+
119
+ </div>
120
+ <script type="module" src="manager.js"></script>
121
+ </body>
122
+ </html>
src/manager/manager.js ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Manager page: model ingestion, registry maintenance, engine settings, setup help. */
2
+ import { ENGINE_STATE, OP, PORT_NAME, PORT_OP, PRIORITY, PROTOCOL, request } from "../adapters/protocol.js";
3
+ import { webExtensionStorage } from "../adapters/webext.js";
4
+ import { ModelStore, formatBytes } from "../engine/model-store.js";
5
+ import { filesFromDataTransfer, filesFromInput, ingestModelFolder } from "../engine/ingest.js";
6
+ import { clampSteps } from "../engine/multistep.js";
7
+
8
+ /**
9
+ * The manager page writes into the same Cache Storage and the same
10
+ * `browser.storage.local` the background page reads, so it builds its own
11
+ * ModelStore over the same adapter rather than routing registry edits through
12
+ * the engine. Only generation goes over the wire.
13
+ */
14
+ const store = new ModelStore(webExtensionStorage());
15
+
16
+ const $ = (id) => document.getElementById(id);
17
+
18
+ const port = browser.runtime.connect({ name: PORT_NAME });
19
+ port.onMessage.addListener((msg) => {
20
+ if (msg?.protocol === PROTOCOL && msg.op === PORT_OP.ENGINE_STATE) renderEngine(msg.state);
21
+ });
22
+
23
+ let lastEngineState = {};
24
+
25
+ function renderEngine(state) {
26
+ lastEngineState = state;
27
+ $("dot").className = `dot ${state.status}`;
28
+ // `size` is engines that exist, which trails `maxSize` until a second task
29
+ // asks for one — so say both, or a pool of 1 under a cap of 2 reads as a bug.
30
+ const pool = state.pool?.size
31
+ ? ` · pool ${state.pool.busy}/${state.pool.size} busy, ${state.pool.queued} queued` +
32
+ (state.pool.growthBlocked
33
+ ? ` · stayed at ${state.pool.size} (${state.pool.growthBlocked})`
34
+ : state.pool.size < state.pool.maxSize
35
+ ? ` · up to ${state.pool.maxSize} on demand`
36
+ : "")
37
+ : "";
38
+ const loading = state.status === ENGINE_STATE.LOADING;
39
+ // Show WebLLM's own report verbatim while loading — shard counts, MB and
40
+ // elapsed seconds are the only feedback there is during a ~48 s load, and a
41
+ // bare percentage hides all of it.
42
+ $("engineStatus").textContent =
43
+ state.error ? state.error :
44
+ state.status === ENGINE_STATE.READY ? `loaded: ${state.modelId}${pool}` :
45
+ loading ? state.progress?.text ?? `loading ${state.modelId}…` :
46
+ "idle";
47
+
48
+ $("engineBar").hidden = !loading;
49
+ $("engineProgress").style.width = `${Math.round((state.progress?.progress ?? 0) * 100)}%`;
50
+ const hint = $("engineHint");
51
+ if (!loading) {
52
+ hint.hidden = true;
53
+ } else {
54
+ const secs = state.progress?.timeElapsed;
55
+ // Not WebLLM's stock "first visit populates the cache" line: these weights
56
+ // were injected by drag-and-drop, so nothing is ever downloaded.
57
+ hint.textContent =
58
+ `${secs ? `${secs}s elapsed · ` : ""}reading from local cache, no network` +
59
+ ((state.progress?.progress ?? 0) > 0.99 ? " · compiling WebGPU shaders" : "") +
60
+ ".";
61
+ hint.hidden = false;
62
+ }
63
+ }
64
+
65
+ // ------------------------------------------------------------ diagnostics ---
66
+
67
+ function renderGpu() {
68
+ const el = $("gpu");
69
+ if (navigator.gpu) {
70
+ el.hidden = false;
71
+ el.textContent = "WebGPU is available in this context.";
72
+ return;
73
+ }
74
+ el.hidden = false;
75
+ el.classList.add("error");
76
+ el.textContent =
77
+ "navigator.gpu is missing. Set dom.webgpu.enabled = true in about:config (see Firefox setup below) and restart Firefox — models cannot load until then.";
78
+ }
79
+
80
+ /** Spells out what another engine actually costs, from the loaded model's own record. */
81
+ async function renderPoolCost() {
82
+ const { engineCount } = await store.getSettings();
83
+ const record = (await store.list()).find((m) => m.model_id === lastEngineState.modelId) ?? (await store.list())[0];
84
+ if (!record) return void ($("poolCost").textContent = "");
85
+ const weights = record.sizeBytes ?? 0;
86
+ const total = weights * engineCount;
87
+ // Measured on an M4 Air with a 0.8B model: 2 engines gave 1.6x aggregate
88
+ // throughput, 4 gave 0.3x - past the memory budget they starve each other.
89
+ const verdict =
90
+ engineCount === 1
91
+ ? "no parallelism: batches run one at a time."
92
+ : engineCount === 2
93
+ ? "measured ~1.6x aggregate throughput on a 0.8B model."
94
+ : "more is usually worse — 4 engines measured 3x SLOWER than 1. Verify with npm run e2e before keeping this.";
95
+ $("poolCost").textContent =
96
+ `${engineCount} engine(s) x ~${formatBytes(weights)} = ~${formatBytes(total)} VRAM. ${verdict}`;
97
+ $("poolCost").classList.toggle("warn", total > 6e9 || engineCount > 2);
98
+ }
99
+
100
+ /**
101
+ * Spells out the sawtooth, because "more steps" is not monotonically better.
102
+ *
103
+ * Firefox resolves a GPU sync only on a 100 ms tick, so a burst of K steps costs
104
+ * a whole number of ticks. The reference figure is the ~7.3 ms/token of real
105
+ * compute measured for a 0.8B (AI.md, "The 10 tok/s ceiling"); a bigger model
106
+ * costs more per step and wants a smaller K.
107
+ */
108
+ const TICK_MS = 100;
109
+ const REFERENCE_STEP_MS = 7.3;
110
+
111
+ async function renderDecodeCost() {
112
+ const { decodeSteps } = await store.getSettings();
113
+ const ticks = Math.ceil((decodeSteps * REFERENCE_STEP_MS) / TICK_MS);
114
+ const rate = decodeSteps / ((ticks * TICK_MS) / 1000);
115
+ const perTick = Math.floor(TICK_MS / REFERENCE_STEP_MS);
116
+ const wastes = decodeSteps > perTick && decodeSteps % perTick !== 0;
117
+ $("decodeCost").textContent =
118
+ `${decodeSteps} step(s) per sync = ${decodeSteps} token(s) every ${ticks} tick(s) ` +
119
+ `≈ ${rate.toFixed(0)} tok/s on a 0.8B (vs 9.6 at 1 step). ` +
120
+ (wastes
121
+ ? `${decodeSteps} spills past a 100 ms tick boundary — ${perTick} fits inside one tick and measures faster. Confirm with npm run e2e.`
122
+ : "Fits the tick grid. Re-check on a larger model: per-step compute grows, so the best K shrinks.");
123
+ $("decodeCost").classList.toggle("warn", wastes);
124
+ }
125
+
126
+ async function renderQuota() {
127
+ if (!navigator.storage?.estimate) return;
128
+ const { usage, quota } = await navigator.storage.estimate();
129
+ $("quota").textContent = `${formatBytes(usage)} of ${formatBytes(quota)}`;
130
+ }
131
+
132
+ // --------------------------------------------------------------- ingestion ---
133
+
134
+ const drop = $("drop");
135
+
136
+ for (const type of ["dragenter", "dragover"]) {
137
+ drop.addEventListener(type, (e) => {
138
+ e.preventDefault();
139
+ drop.classList.add("over");
140
+ });
141
+ }
142
+ for (const type of ["dragleave", "drop"]) {
143
+ drop.addEventListener(type, () => drop.classList.remove("over"));
144
+ }
145
+ drop.addEventListener("drop", async (e) => {
146
+ e.preventDefault();
147
+ await ingest(await filesFromDataTransfer(e.dataTransfer));
148
+ });
149
+
150
+ $("pick").addEventListener("click", () => $("picker").click());
151
+ $("picker").addEventListener("change", async (e) => {
152
+ await ingest(filesFromInput(e.target.files));
153
+ e.target.value = "";
154
+ });
155
+
156
+ async function ingest(entries) {
157
+ $("ingestError").hidden = true;
158
+ $("ingest").hidden = false;
159
+ $("ingestBar").style.width = "0%";
160
+ $("ingestStatus").textContent = "Validating…";
161
+
162
+ try {
163
+ const record = await ingestModelFolder(entries, {
164
+ store,
165
+ onProgress: ({ phase, done, total, label }) => {
166
+ $("ingestBar").style.width = `${Math.round((done / Math.max(total, 1)) * 100)}%`;
167
+ $("ingestStatus").textContent =
168
+ phase === "validating" ? label : `Caching ${done}/${total} — ${label}`;
169
+ },
170
+ });
171
+ $("ingestStatus").textContent = `Registered ${record.model_id} (${formatBytes(record.sizeBytes)}, ${record.shardCount} shards).`;
172
+ await Promise.all([renderModels(), renderQuota()]);
173
+ } catch (err) {
174
+ $("ingest").hidden = true;
175
+ $("ingestError").hidden = false;
176
+ $("ingestError").textContent = err.message;
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------- registry ---
181
+
182
+ async function renderModels() {
183
+ const models = await store.list();
184
+ const tbody = $("models").querySelector("tbody");
185
+ tbody.replaceChildren();
186
+ $("models").hidden = models.length === 0;
187
+ $("noModels").hidden = models.length > 0;
188
+
189
+ for (const record of models) {
190
+ const tr = document.createElement("tr");
191
+ tr.innerHTML = `
192
+ <td><strong></strong><br /><span class="muted mono"></span></td>
193
+ <td class="mono"></td>
194
+ <td class="mono"></td>
195
+ <td class="mono"></td>
196
+ <td><span class="pill">checking…</span></td>
197
+ <td class="row"><button data-act="load">Load</button><button data-act="remove" class="danger">Remove</button></td>`;
198
+
199
+ tr.querySelector("strong").textContent = record.model_id;
200
+ tr.querySelector("td .muted").textContent = `added ${new Date(record.addedAt).toLocaleString()}`;
201
+ const cells = tr.querySelectorAll("td.mono");
202
+ cells[0].textContent = formatBytes(record.sizeBytes);
203
+ cells[1].textContent = record.shardCount ?? "—";
204
+ cells[2].textContent = record.wasm ?? "—";
205
+
206
+ tr.querySelector('[data-act="load"]').addEventListener("click", async (e) => {
207
+ e.target.disabled = true;
208
+ const res = await browser.runtime.sendMessage(request(OP.LOAD, { modelId: record.model_id }));
209
+ e.target.disabled = false;
210
+ if (!res?.ok) renderEngine({ status: ENGINE_STATE.ERROR, error: res?.error });
211
+ });
212
+ tr.querySelector('[data-act="remove"]').addEventListener("click", async () => {
213
+ if (!confirm(`Remove "${record.model_id}" and free ${formatBytes(record.sizeBytes)} of cache?`)) return;
214
+ await store.remove(record.model_id);
215
+ await Promise.all([renderModels(), renderQuota()]);
216
+ });
217
+
218
+ tbody.append(tr);
219
+
220
+ store.verify(record).then(({ ok, missing }) => {
221
+ const pill = tr.querySelector(".pill");
222
+ pill.classList.add(ok ? "ok" : "bad");
223
+ pill.textContent = ok ? "complete" : `${missing.length} missing`;
224
+ });
225
+ }
226
+ }
227
+
228
+ // ---------------------------------------------------------------- settings ---
229
+
230
+ async function renderSettings() {
231
+ const s = await store.getSettings();
232
+ $("temperature").value = s.temperature;
233
+ $("maxTokens").value = s.maxTokens;
234
+ $("engineCount").value = s.engineCount;
235
+ $("decodeSteps").value = s.decodeSteps;
236
+ $("systemPrompt").value = s.systemPrompt;
237
+ $("allowedExternalIds").value = s.allowedExternalIds.join(", ");
238
+ }
239
+
240
+ $("save").addEventListener("click", async () => {
241
+ await store.setSettings({
242
+ temperature: Number($("temperature").value),
243
+ maxTokens: Number($("maxTokens").value),
244
+ engineCount: Math.max(1, Math.min(4, Number($("engineCount").value) || 1)),
245
+ decodeSteps: clampSteps($("decodeSteps").value),
246
+ systemPrompt: $("systemPrompt").value,
247
+ allowedExternalIds: $("allowedExternalIds").value.split(",").map((s) => s.trim()).filter(Boolean),
248
+ });
249
+ $("saved").hidden = false;
250
+ setTimeout(() => ($("saved").hidden = true), 1500);
251
+ await renderPoolCost();
252
+ await renderDecodeCost();
253
+ renderEngine({ ...lastEngineState, note: "reload the model for the pool size to take effect" });
254
+ });
255
+
256
+ // ------------------------------------------------------------- Engine API ---
257
+
258
+ /**
259
+ * The three shapes of work, as copy-paste starting points.
260
+ *
261
+ * They differ only in scheduling metadata, not in op — that is the whole point,
262
+ * and the reason there is no `translate` op to call. Each carries the one field
263
+ * that makes it behave correctly, because those are what callers get wrong.
264
+ */
265
+ function apiSamples(id) {
266
+ return {
267
+ completion: {
268
+ why: "Latency is the product. `session` is what makes this work — reuse one key and the engine drops the stale request itself, so a fast typist never queues a request per keystroke.",
269
+ code: `const port = browser.runtime.connect("${id}", { name: "${PORT_NAME}" });
270
+ port.onMessage.addListener((m) => {
271
+ if (m.op === "${PORT_OP.CHUNK}") render(m.delta);
272
+ if (m.op === "${PORT_OP.DONE}") finish(m.text);
273
+ });
274
+
275
+ // On every keystroke. The previous request is superseded, not queued.
276
+ port.postMessage({
277
+ protocol: "${PROTOCOL}",
278
+ op: "${PORT_OP.CHAT_STREAM}",
279
+ id: crypto.randomUUID(),
280
+ session: "ghost-text", // supersession key — the field that matters
281
+ priority: "${PRIORITY.INTERACTIVE}", // may preempt work that opted in
282
+ max_tokens: 24,
283
+ messages: [{ role: "user", content: prefix }],
284
+ });`,
285
+ },
286
+ translation: {
287
+ why: "One `batch`, not a loop of `chat` calls: one round trip, and the engine schedules the whole page as a single task that can never occupy more than one engine.",
288
+ code: `const res = await browser.runtime.sendMessage("${id}", {
289
+ protocol: "${PROTOCOL}",
290
+ op: "${OP.BATCH}",
291
+ task: "translate-page", // optional; a batch is one task either way
292
+ requests: sentences.map((s) => ({
293
+ messages: [{ role: "user", content: \`Translate to French, output only the translation:\\n\${s}\` }],
294
+ })),
295
+ });
296
+ if (!res.ok) throw new Error(res.error);
297
+ res.results.forEach((r) => apply(r.index, r.text));`,
298
+ },
299
+ reformat: {
300
+ why: "Nobody is watching, so let interactive work cut in. Set `preemptible` on the job that can afford to lose — a preempted job resolves with `preempted: true` and its partial text, never requeued.",
301
+ code: `const res = await browser.runtime.sendMessage("${id}", {
302
+ protocol: "${PROTOCOL}",
303
+ op: "${OP.CHAT}",
304
+ priority: "${PRIORITY.BACKGROUND}",
305
+ preemptible: true, // the direction matters
306
+ max_tokens: 2048,
307
+ messages: [{ role: "user", content: \`Reformat as clean Markdown, no commentary:\\n\\n\${doc}\` }],
308
+ });
309
+ if (res.preempted) keepOrDiscard(res.text);`,
310
+ },
311
+ };
312
+ }
313
+
314
+ function renderApiSample() {
315
+ const id = browser.runtime.id;
316
+ const samples = apiSamples(id);
317
+ $("selfId").textContent = id;
318
+
319
+ const show = (name) => {
320
+ for (const tab of document.querySelectorAll(".tab")) {
321
+ tab.classList.toggle("active", tab.dataset.tab === name);
322
+ }
323
+ $("apiWhy").textContent = samples[name].why;
324
+ $("apiSample").textContent = samples[name].code;
325
+ };
326
+
327
+ for (const tab of document.querySelectorAll(".tab")) {
328
+ tab.addEventListener("click", () => show(tab.dataset.tab));
329
+ }
330
+ show("completion");
331
+
332
+ const flash = (btn, label) => {
333
+ const original = btn.textContent;
334
+ btn.textContent = label;
335
+ setTimeout(() => (btn.textContent = original), 1200);
336
+ };
337
+ $("copyId").addEventListener("click", () =>
338
+ navigator.clipboard.writeText(id).then(() => flash($("copyId"), "Copied")),
339
+ );
340
+ $("copyCode").addEventListener("click", () =>
341
+ navigator.clipboard.writeText($("apiSample").textContent).then(() => flash($("copyCode"), "Copied")),
342
+ );
343
+ }
344
+
345
+ renderGpu();
346
+ renderApiSample();
347
+ await Promise.all([renderModels(), renderSettings(), renderQuota()]);
348
+ await renderPoolCost();
349
+ await renderDecodeCost();
350
+ browser.runtime.sendMessage(request(OP.STATUS)).then((res) => res?.ok && renderEngine(res.state));
src/popup/popup.css ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body { width: 420px; height: 580px; display: flex; flex-direction: column; }
2
+
3
+ header, footer { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
4
+ header { border-bottom: 1px solid var(--line); }
5
+ footer { border-top: 1px solid var(--line); gap: 8px; }
6
+
7
+ main {
8
+ flex: 1;
9
+ overflow-y: auto;
10
+ padding: 16px 14px;
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: 18px;
14
+ }
15
+
16
+ /* Claude does not put the assistant in a bubble: the reply is just text on the
17
+ page, and only the user's turn gets a surface. Keeping that asymmetry is most
18
+ of what makes a transcript read as Claude's rather than as a chat app's. */
19
+ .msg { white-space: pre-wrap; word-break: break-word; }
20
+ .msg.user {
21
+ align-self: flex-end;
22
+ max-width: 85%;
23
+ background: var(--panel-strong);
24
+ border-radius: 12px;
25
+ padding: 9px 13px;
26
+ }
27
+ .msg.assistant { align-self: stretch; }
28
+ .msg.error {
29
+ align-self: stretch;
30
+ border: 1px solid var(--accent);
31
+ border-radius: 8px;
32
+ padding: 9px 13px;
33
+ }
34
+ .msg .meta { display: block; margin-top: 6px; font-size: 11px; color: var(--muted); }
35
+
36
+ .empty { margin: auto; text-align: center; color: var(--muted); max-width: 260px; }
37
+
38
+ #input {
39
+ resize: none;
40
+ width: 100%;
41
+ background: var(--panel);
42
+ border-radius: 12px;
43
+ padding: 10px 12px;
44
+ }
45
+ #input:focus { border-color: var(--line-strong); }
46
+
47
+ #status { min-height: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
48
+ /* The load report is a sentence, not a label: let it wrap while it is showing,
49
+ otherwise it is silently truncated to "Loading model from cache[26/5…". */
50
+ #status.loading { white-space: normal; overflow: visible; text-overflow: clip; }
src/popup/popup.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Everything WebGPU</title>
6
+ <link rel="stylesheet" href="../ui.css" />
7
+ <link rel="stylesheet" href="popup.css" />
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <div class="row">
12
+ <span id="dot" class="dot"></span>
13
+ <h1 class="grow">Everything WebGPU</h1>
14
+ <button id="manage" title="Manage local models">Models…</button>
15
+ </div>
16
+ <div class="row">
17
+ <select id="model" class="grow"></select>
18
+ <button id="load" class="primary">Load</button>
19
+ <button id="unload" title="Free VRAM">Eject</button>
20
+ </div>
21
+ <div class="bar"><i id="progress"></i></div>
22
+ <div id="status" class="muted mono">Checking engine…</div>
23
+ <div id="loadHint" class="muted hint" hidden></div>
24
+ </header>
25
+
26
+ <main id="log" aria-live="polite"></main>
27
+
28
+ <footer>
29
+ <textarea id="input" rows="2" placeholder="Ask the local model…"></textarea>
30
+ <div class="row">
31
+ <button id="clear">Clear</button>
32
+ <span class="grow"></span>
33
+ <button id="stop" class="danger" hidden>Stop</button>
34
+ <button id="send" class="primary">Send</button>
35
+ </div>
36
+ </footer>
37
+
38
+ <script type="module" src="popup.js"></script>
39
+ </body>
40
+ </html>
src/popup/popup.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Minimal test chat. Thin client over the background engine host's port protocol. */
2
+ import { ENGINE_STATE, OP, PORT_NAME, PORT_OP, PRIORITY, PROTOCOL, request } from "../adapters/protocol.js";
3
+
4
+ const $ = (id) => document.getElementById(id);
5
+ const els = {
6
+ dot: $("dot"), model: $("model"), load: $("load"), unload: $("unload"),
7
+ progress: $("progress"), status: $("status"), loadHint: $("loadHint"), log: $("log"), input: $("input"),
8
+ send: $("send"), stop: $("stop"), clear: $("clear"), manage: $("manage"),
9
+ };
10
+
11
+ const history = [];
12
+ let models = [];
13
+ let engineState = { status: ENGINE_STATE.IDLE, modelId: null, pool: null };
14
+ let streamId = null;
15
+ let streamEl = null;
16
+
17
+ /**
18
+ * The engine supersedes by `session`, so a cleared conversation has to become a
19
+ * *different* session — otherwise the next message looks to the scheduler like
20
+ * a continuation of the one just thrown away.
21
+ */
22
+ const newSession = () => `popup-${crypto.randomUUID()}`;
23
+ let session = newSession();
24
+
25
+ const port = browser.runtime.connect({ name: PORT_NAME });
26
+ port.onMessage.addListener(onPortMessage);
27
+
28
+ // -------------------------------------------------------------- rendering ---
29
+
30
+ /**
31
+ * The second line of the load guide.
32
+ *
33
+ * Deliberately not WebLLM's stock wording. Its default hint is about populating
34
+ * a cache from the network on first visit, which is never what happens here:
35
+ * the weights were injected into Cache Storage by drag-and-drop, so nothing is
36
+ * downloaded and a "later refreshes are faster" promise would be wrong. What
37
+ * the user is actually waiting on, once the shards are read, is WebGPU shader
38
+ * compilation.
39
+ */
40
+ function renderLoadHint(progress) {
41
+ if (!progress) return void (els.loadHint.hidden = true);
42
+ const secs = progress.timeElapsed;
43
+ const parts = [];
44
+ if (progress.engines > 1) parts.push(`${progress.engines} engines, loaded 2 at a time`);
45
+ parts.push("weights come from the local cache — nothing is downloaded");
46
+ if ((progress.progress ?? 0) > 0.99) {
47
+ parts.push("compiling WebGPU shaders, which is most of the wait");
48
+ }
49
+ els.loadHint.textContent = `${secs ? `${secs}s elapsed · ` : ""}${parts.join(" · ")}.`;
50
+ els.loadHint.hidden = false;
51
+ }
52
+
53
+ function renderState() {
54
+ const { status, modelId, progress, error, pool } = engineState;
55
+ els.dot.className = `dot ${status}`;
56
+ els.progress.style.width = `${Math.round((progress?.progress ?? (status === ENGINE_STATE.READY ? 1 : 0)) * 100)}%`;
57
+
58
+ els.status.textContent =
59
+ error ? error :
60
+ progress?.text ? progress.text :
61
+ status === ENGINE_STATE.READY ? `Ready — ${modelId}` :
62
+ status === ENGINE_STATE.LOADING ? `Loading ${modelId}…` :
63
+ "Idle — pick a model and press Load";
64
+ if (!error && status === ENGINE_STATE.READY && pool && (pool.busy || pool.queued)) {
65
+ const cap = pool.size < pool.maxSize && !pool.growthBlocked ? `/${pool.maxSize}` : "";
66
+ els.status.textContent += ` · ${pool.busy}/${pool.size}${cap} busy, ${pool.queued} queued`;
67
+ }
68
+ els.status.classList.toggle("banner", Boolean(error));
69
+ els.status.classList.toggle("error", Boolean(error));
70
+
71
+ const loading = status === ENGINE_STATE.LOADING;
72
+ // The report is a full sentence from WebLLM ("Loading model from cache[26/58]:
73
+ // 890MB loaded. 51% completed, 121 secs elapsed."). #status is a single
74
+ // ellipsised line by default, which cut it off, so unclip it while loading.
75
+ els.status.classList.toggle("loading", loading && Boolean(progress?.text));
76
+ renderLoadHint(loading ? progress : null);
77
+ els.load.disabled = loading || !els.model.value;
78
+ els.unload.disabled = loading || status !== ENGINE_STATE.READY;
79
+ els.model.disabled = loading;
80
+ els.send.disabled = loading || streamId !== null;
81
+ els.stop.hidden = streamId === null;
82
+ els.load.textContent = status === ENGINE_STATE.READY && modelId === els.model.value ? "Reload" : "Load";
83
+ }
84
+
85
+ function renderEmpty() {
86
+ els.log.replaceChildren(
87
+ Object.assign(document.createElement("div"), {
88
+ className: "empty",
89
+ textContent: models.length
90
+ ? "Load a model, then send a message."
91
+ : "No local models yet. Open Models… and drop a compiled MLC model folder.",
92
+ }),
93
+ );
94
+ }
95
+
96
+ function addMessage(role, text) {
97
+ if (els.log.querySelector(".empty")) els.log.replaceChildren();
98
+ const el = document.createElement("div");
99
+ el.className = `msg ${role}`;
100
+ el.textContent = text;
101
+ els.log.append(el);
102
+ els.log.scrollTop = els.log.scrollHeight;
103
+ return el;
104
+ }
105
+
106
+ // ---------------------------------------------------------------- actions ---
107
+
108
+ async function ask(op, payload) {
109
+ const res = await browser.runtime.sendMessage(request(op, payload));
110
+ if (!res?.ok) throw new Error(res?.error ?? "Engine did not respond.");
111
+ return res;
112
+ }
113
+
114
+ async function refreshModels() {
115
+ const res = await ask(OP.LIST_MODELS);
116
+ models = res.models;
117
+ engineState = res.state;
118
+ els.model.replaceChildren(
119
+ ...models.map((m) => new Option(`${m.model_id} · ${(m.sizeBytes / 1e9).toFixed(1)} GB`, m.model_id)),
120
+ );
121
+ if (res.state.modelId) els.model.value = res.state.modelId;
122
+ if (!els.log.children.length || els.log.querySelector(".empty")) renderEmpty();
123
+ renderState();
124
+ }
125
+
126
+ function send() {
127
+ const text = els.input.value.trim();
128
+ if (!text) return;
129
+ els.input.value = "";
130
+ history.push({ role: "user", content: text });
131
+ addMessage("user", text);
132
+
133
+ streamId = crypto.randomUUID();
134
+ streamEl = addMessage("assistant", "");
135
+ renderState();
136
+
137
+ port.postMessage({
138
+ protocol: PROTOCOL,
139
+ op: PORT_OP.CHAT_STREAM,
140
+ id: streamId,
141
+ modelId: els.model.value || undefined,
142
+ messages: history,
143
+ priority: PRIORITY.INTERACTIVE,
144
+ session,
145
+ });
146
+ }
147
+
148
+ function onPortMessage(msg) {
149
+ if (msg?.protocol !== PROTOCOL) return;
150
+ switch (msg.op) {
151
+ case PORT_OP.ENGINE_STATE:
152
+ engineState = msg.state;
153
+ if (msg.state.modelId && !els.model.value) els.model.value = msg.state.modelId;
154
+ renderState();
155
+ return;
156
+ case PORT_OP.CHUNK:
157
+ if (msg.id !== streamId) return;
158
+ streamEl.textContent += msg.delta;
159
+ els.log.scrollTop = els.log.scrollHeight;
160
+ return;
161
+ case PORT_OP.DONE: {
162
+ if (msg.id !== streamId) return;
163
+ const text = streamEl.textContent;
164
+ history.push({ role: "assistant", content: text });
165
+ if (msg.usage) {
166
+ const meta = document.createElement("span");
167
+ meta.className = "meta";
168
+ meta.textContent = `${msg.usage.completion_tokens} tok · ${(msg.usage.extra?.decode_tokens_per_s ?? 0).toFixed(1)} tok/s`;
169
+ streamEl.append(meta);
170
+ }
171
+ streamId = null;
172
+ streamEl = null;
173
+ renderState();
174
+ return;
175
+ }
176
+ case PORT_OP.ERROR:
177
+ if (streamEl && !streamEl.textContent) streamEl.remove();
178
+ addMessage("error", msg.error);
179
+ streamId = null;
180
+ streamEl = null;
181
+ renderState();
182
+ return;
183
+ }
184
+ }
185
+
186
+ // ------------------------------------------------------------------ wiring ---
187
+
188
+ els.load.addEventListener("click", () =>
189
+ ask(OP.LOAD, { modelId: els.model.value }).catch((err) => {
190
+ engineState = { ...engineState, status: ENGINE_STATE.ERROR, error: err.message };
191
+ renderState();
192
+ }),
193
+ );
194
+ els.unload.addEventListener("click", () => ask(OP.UNLOAD).catch(() => {}));
195
+ els.send.addEventListener("click", send);
196
+ els.stop.addEventListener("click", () =>
197
+ port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ABORT, id: streamId, session }),
198
+ );
199
+
200
+ /**
201
+ * Discard the conversation and start a fresh session.
202
+ *
203
+ * All three parts matter. Dropping `history` alone left every rendered message
204
+ * on screen, which read as the button doing nothing; and leaving the old
205
+ * session key in place meant a request that was still streaming kept writing
206
+ * into a conversation the user had already thrown away.
207
+ */
208
+ els.clear.addEventListener("click", () => {
209
+ if (streamId) {
210
+ port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ABORT, id: streamId, session });
211
+ streamId = null;
212
+ streamEl = null;
213
+ }
214
+ history.length = 0;
215
+ session = newSession();
216
+ renderEmpty();
217
+ els.input.value = "";
218
+ renderState();
219
+ });
220
+ els.manage.addEventListener("click", () => {
221
+ browser.runtime.openOptionsPage();
222
+ window.close();
223
+ });
224
+ els.model.addEventListener("change", renderState);
225
+ els.input.addEventListener("keydown", (e) => {
226
+ if (e.key === "Enter" && !e.shiftKey) {
227
+ e.preventDefault();
228
+ if (!els.send.disabled) send();
229
+ }
230
+ });
231
+
232
+ refreshModels().catch((err) => {
233
+ els.status.textContent = err.message;
234
+ els.status.classList.add("banner", "error");
235
+ });
src/ui.css ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared UI tokens, in Claude's palette.
3
+ *
4
+ * Exactly three colours are declared per theme — a warm off-white ground, a
5
+ * near-black ink, and Claude's coral accent. Everything that looks like a
6
+ * fourth colour (borders, muted text, panels) is one of those three at reduced
7
+ * alpha, so the whole surface stays in one family and nothing needs picking a
8
+ * new hue to add a component.
9
+ *
10
+ * Fonts are a system stack. Claude's own Styrene and Tiempos are licensed
11
+ * faces, and this extension does no network requests by design, so there is
12
+ * nothing to load them from — the stack below is the closest available match.
13
+ */
14
+ :root {
15
+ --bg: #faf9f5;
16
+ --ink: #141413;
17
+ --accent: #d97757;
18
+
19
+ --panel: color-mix(in srgb, var(--ink) 4%, var(--bg));
20
+ --panel-strong: color-mix(in srgb, var(--ink) 7%, var(--bg));
21
+ --line: color-mix(in srgb, var(--ink) 10%, transparent);
22
+ --line-strong: color-mix(in srgb, var(--ink) 18%, transparent);
23
+ --muted: color-mix(in srgb, var(--ink) 52%, transparent);
24
+ --on-accent: #ffffff;
25
+
26
+ color-scheme: light;
27
+ }
28
+
29
+ @media (prefers-color-scheme: dark) {
30
+ :root {
31
+ --bg: #262624;
32
+ --ink: #f5f4ef;
33
+ --accent: #d97757;
34
+
35
+ --panel: color-mix(in srgb, var(--ink) 6%, var(--bg));
36
+ --panel-strong: color-mix(in srgb, var(--ink) 10%, var(--bg));
37
+ --line: color-mix(in srgb, var(--ink) 14%, transparent);
38
+ --line-strong: color-mix(in srgb, var(--ink) 24%, transparent);
39
+ --muted: color-mix(in srgb, var(--ink) 55%, transparent);
40
+
41
+ color-scheme: dark;
42
+ }
43
+ }
44
+
45
+ * { box-sizing: border-box; }
46
+
47
+ body {
48
+ margin: 0;
49
+ background: var(--bg);
50
+ color: var(--ink);
51
+ font: 14px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
52
+ -webkit-font-smoothing: antialiased;
53
+ }
54
+
55
+ h1, h2 { margin: 0; font-weight: 500; letter-spacing: -0.01em; }
56
+ h1 { font-size: 15px; }
57
+ h2 { font-size: 12px; color: var(--muted); font-weight: 500; letter-spacing: .04em; text-transform: uppercase; }
58
+
59
+ button, select, input, textarea {
60
+ font: inherit;
61
+ color: var(--ink);
62
+ background: transparent;
63
+ border: 1px solid var(--line-strong);
64
+ border-radius: 8px;
65
+ padding: 7px 12px;
66
+ transition: background .12s ease, border-color .12s ease, opacity .12s ease;
67
+ }
68
+ select { background: var(--panel); }
69
+ button { cursor: pointer; }
70
+ button:hover:not(:disabled) { background: var(--panel-strong); }
71
+ button:disabled { opacity: .35; cursor: default; }
72
+
73
+ button.primary {
74
+ background: var(--accent);
75
+ color: var(--on-accent);
76
+ border-color: var(--accent);
77
+ font-weight: 500;
78
+ }
79
+ button.primary:hover:not(:disabled) { background: color-mix(in srgb, var(--ink) 12%, var(--accent)); }
80
+ /* Destructive actions read as plain text until hovered — Claude does not spend
81
+ a colour on them, and there is no fourth colour to spend. */
82
+ button.danger { border-color: transparent; color: var(--muted); }
83
+ button.danger:hover:not(:disabled) { color: var(--accent); background: var(--panel-strong); }
84
+
85
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
86
+
87
+ .row { display: flex; gap: 8px; align-items: center; }
88
+ .grow { flex: 1; min-width: 0; }
89
+ .muted { color: var(--muted); }
90
+ .mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; }
91
+
92
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--line-strong); flex: none; }
93
+ .dot.ready { background: var(--accent); }
94
+ .dot.loading { background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
95
+ .dot.error { background: var(--ink); }
96
+ @keyframes pulse { 50% { opacity: .3; } }
97
+
98
+ .hint { font-size: 12px; line-height: 1.45; margin-top: 3px; }
99
+ .bar { height: 2px; background: var(--line); border-radius: 2px; overflow: hidden; }
100
+ .bar > i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .2s; }
101
+
102
+ .banner {
103
+ padding: 10px 12px;
104
+ border-radius: 8px;
105
+ border: 1px solid var(--line-strong);
106
+ background: var(--panel);
107
+ }
108
+ .banner.error { border-color: var(--accent); }
src/vite.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The Vite plugin, because one line of config you have to *remember* is one
3
+ * line of config you will forget.
4
+ *
5
+ * ```js
6
+ * import { everythingWebGPU } from "everything-webgpu/vite";
7
+ * export default defineConfig({ plugins: [everythingWebGPU()] });
8
+ * ```
9
+ *
10
+ * ## What it fixes, precisely
11
+ *
12
+ * The engine spawns its decode worker with
13
+ * `new Worker(new URL("./engine-worker.js", import.meta.url), { type: "module" })`.
14
+ * Vite's dependency pre-bundler runs esbuild over `node_modules` packages and
15
+ * copies that expression through **verbatim** — but the copy now lives in
16
+ * `node_modules/.vite/deps/everything-webgpu.js`, so `import.meta.url` points
17
+ * there and the worker resolves to `.vite/deps/engine-worker.js`, which does not
18
+ * exist. The real file is still at
19
+ * `node_modules/everything-webgpu/src/engine/engine-worker.js`.
20
+ *
21
+ * Measured, not assumed. On a real (non-linked) install of this package:
22
+ *
23
+ * | | worker resolves | notes |
24
+ * | --- | --- | --- |
25
+ * | `vite build` | ✅ | Rollup handles it; output is byte-identical with or without this plugin |
26
+ * | `vite dev`, excluded | ✅ | source served from `/@fs/`, `import.meta.url` is correct |
27
+ * | `vite dev`, not excluded | ❌ 404 | the case this exists for |
28
+ *
29
+ * Two consequences worth stating, because both mislead:
30
+ *
31
+ * 1. **`vite build` never reproduces the bug.** A green production build is
32
+ * not evidence that the dev server works.
33
+ * 2. **A linked (`file:`) dependency never reproduces it either**, because
34
+ * Vite does not pre-bundle linked packages. Every example in this repo is
35
+ * linked, so none of them can catch this — only a real install can. See
36
+ * `npm run verify-consumer`.
37
+ *
38
+ * The lazy `import("../../vendor/web-llm.js")` is *not* affected: esbuild
39
+ * rewrites that one correctly to a hashed chunk. Only the worker breaks.
40
+ *
41
+ * If you would rather not add a plugin, the equivalent is:
42
+ *
43
+ * ```js
44
+ * optimizeDeps: { exclude: ["everything-webgpu"] }
45
+ * ```
46
+ *
47
+ * and if you do neither, `load()` fails with `PACKAGE_INCOMPLETE` naming this
48
+ * fix rather than hanging on a worker that will never answer.
49
+ */
50
+
51
+ /** The package's own name, so the exclusion cannot drift from it. */
52
+ const PACKAGE = "everything-webgpu";
53
+
54
+ /**
55
+ * @param {{exclude?: string[]}} [opts] `exclude` adds further specifiers, for a
56
+ * host that re-exports this engine from its own package and hits the same
57
+ * pre-bundling of the worker URL.
58
+ * @returns {import("vite").Plugin}
59
+ */
60
+ export function everythingWebGPU({ exclude = [] } = {}) {
61
+ return {
62
+ name: "everything-webgpu",
63
+ // `config` rather than `configResolved`: this has to merge into the user's
64
+ // options before Vite computes the optimizer's entries, not after.
65
+ config: () => ({
66
+ optimizeDeps: { exclude: [PACKAGE, ...exclude] },
67
+ }),
68
+ };
69
+ }
70
+
71
+ export default everythingWebGPU;
tsconfig.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "//": "Type declarations only. The source stays plain ESM JavaScript; the JSDoc it already carries is the type source, so `npm run types` adds a typed surface for consumers without a rewrite or a build step on the runtime path.",
3
+ "compilerOptions": {
4
+ "allowJs": true,
5
+ "checkJs": false,
6
+ "declaration": true,
7
+ "emitDeclarationOnly": true,
8
+ "outDir": "types",
9
+ "target": "ES2022",
10
+ "module": "ESNext",
11
+ "moduleResolution": "bundler",
12
+ "lib": [
13
+ "ES2022",
14
+ "DOM",
15
+ "DOM.Iterable",
16
+ "WebWorker"
17
+ ],
18
+ "skipLibCheck": true,
19
+ "strict": false,
20
+ "rootDir": "src"
21
+ },
22
+ "include": [
23
+ "src/engine/**/*.js",
24
+ "src/adapters/**/*.js",
25
+ "src/vite.js"
26
+ ],
27
+ "exclude": [
28
+ "src/engine/engine-worker.js",
29
+ "vendor"
30
+ ]
31
+ }
types/adapters/idb.d.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @returns {Promise<import("../engine/model-store.js").StorageAdapter>} */
2
+ export function indexedDBStorage(): Promise<import("../engine/model-store.js").StorageAdapter>;
3
+ /**
4
+ * Ask the browser to stop treating this origin's storage as evictable.
5
+ *
6
+ * Firefox prompts the user (or grants silently for a site with the permission
7
+ * already); Chrome decides from engagement heuristics without a prompt. Either
8
+ * way a caller must handle `persisted: false` — the model still works, it can
9
+ * just be dropped under disk pressure, which `ModelStore.verify()` will catch
10
+ * on the next load.
11
+ *
12
+ * @returns {Promise<{persisted: boolean, quota?: number, usage?: number}>}
13
+ */
14
+ export function ensurePersistent(): Promise<{
15
+ persisted: boolean;
16
+ quota?: number;
17
+ usage?: number;
18
+ }>;
types/adapters/memory.d.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * In-memory StorageAdapter.
3
+ *
4
+ * For tests, and for a host that genuinely wants the registry to die with the
5
+ * page. Note that the *weights* still live in Cache Storage and survive — only
6
+ * the registry entry pointing at them is lost, which `ModelStore.verify()` will
7
+ * then never be asked about. Do not use this in production for that reason.
8
+ */
9
+ /** @returns {import("../engine/model-store.js").StorageAdapter} */
10
+ export function memoryStorage(initial?: {}): import("../engine/model-store.js").StorageAdapter;
types/adapters/protocol.d.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function request(op: any, payload?: {}): {
2
+ protocol: string;
3
+ op: any;
4
+ };
5
+ export const PROTOCOL: "everything-webgpu/v1";
6
+ export const PORT_NAME: "everything-webgpu/v1";
7
+ export namespace OP {
8
+ let STATUS: string;
9
+ let LIST_MODELS: string;
10
+ let LOAD: string;
11
+ let UNLOAD: string;
12
+ let CHAT: string;
13
+ let BATCH: string;
14
+ let CANCEL: string;
15
+ let CONFIGURE: string;
16
+ }
17
+ export namespace PORT_OP {
18
+ let CHAT_STREAM: string;
19
+ let BATCH_STREAM: string;
20
+ let ABORT: string;
21
+ let SUBSCRIBE: string;
22
+ let CHUNK: string;
23
+ let ITEM: string;
24
+ let DONE: string;
25
+ let ERROR: string;
26
+ let ENGINE_STATE: string;
27
+ }
28
+ export { ENGINE_STATE, PRIORITY, PRIORITY_ORDER } from "../engine/constants.js";
types/adapters/webext.d.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `browser.storage.local` already *is* the StorageAdapter shape — `get(key)`
3
+ * and `set(obj)`. The two-method interface was chosen for that reason, so this
4
+ * is a passthrough rather than a translation layer.
5
+ *
6
+ * @returns {import("../engine/model-store.js").StorageAdapter}
7
+ */
8
+ export function webExtensionStorage(): import("../engine/model-store.js").StorageAdapter;
9
+ /**
10
+ * Wires a ScheduledEngine onto this extension's runtime messaging.
11
+ *
12
+ * @param {import("../engine/engine.js").ScheduledEngine} engine
13
+ * @returns {() => void} detach
14
+ */
15
+ export function attachWebExtensionTransport(engine: import("../engine/engine.js").ScheduledEngine): () => void;
types/engine/chat.d.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @param {import("./engine.js").ScheduledEngine} engine */
2
+ export function chatFacade(engine: import("./engine.js").ScheduledEngine): {
3
+ completions: {
4
+ /**
5
+ * @param {import("./engine.js").CompletionRequest & {
6
+ * stream?: boolean, stream_options?: {include_usage?: boolean} }} req
7
+ * @returns {Promise<object | AsyncIterable<object>>} a completion, or a
8
+ * stream of chunks when `stream` is set — the same two shapes WebLLM
9
+ * returns, so `await`ing then `for await`ing works unchanged.
10
+ */
11
+ create(req: import("./engine.js").CompletionRequest & {
12
+ stream?: boolean;
13
+ stream_options?: {
14
+ include_usage?: boolean;
15
+ };
16
+ }): Promise<object | AsyncIterable<object>>;
17
+ };
18
+ };
types/engine/constants.d.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export namespace PRIORITY {
2
+ let INTERACTIVE: string;
3
+ let NORMAL: string;
4
+ let BACKGROUND: string;
5
+ }
6
+ export const PRIORITY_ORDER: string[];
7
+ export namespace UNLOAD_LEVEL {
8
+ let VRAM: string;
9
+ let CACHE: string;
10
+ }
11
+ export namespace JOB_KIND {
12
+ let CHAT: string;
13
+ let EMBEDDING: string;
14
+ }
15
+ export namespace ENGINE_STATE {
16
+ let IDLE: string;
17
+ let LOADING: string;
18
+ let READY: string;
19
+ let ERROR: string;
20
+ }
21
+ /**
22
+ * Out-of-band message kind for configuring an engine worker before WebLLM's own
23
+ * handshake starts. Distinct from every WebLLM `kind`, so the worker can route
24
+ * on it without parsing the rest.
25
+ */
26
+ export const WORKER_CONFIGURE: "everything-webgpu/configure";
types/engine/create.d.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @param {string} [modelId] loaded before returning, as `CreateMLCEngine` does.
3
+ * Omit to get an engine that loads later.
4
+ * @param {object} [opts]
5
+ * @param {ModelStore | import("./model-store.js").StorageAdapter} [opts.store]
6
+ * defaults to IndexedDB. Pass one explicitly in a worker or a test.
7
+ * @param {(report: {text: string, progress: number}) => void} [opts.initProgressCallback]
8
+ * @param {string | URL} [opts.workerUrl]
9
+ * @param {() => Promise<object>} [opts.loadWebLLM]
10
+ * @param {boolean} [opts.prebuilt]
11
+ * @returns {Promise<ScheduledEngine>}
12
+ */
13
+ export function CreateScheduledEngine(modelId?: string, opts?: {
14
+ store?: ModelStore | import("./model-store.js").StorageAdapter;
15
+ initProgressCallback?: (report: {
16
+ text: string;
17
+ progress: number;
18
+ }) => void;
19
+ workerUrl?: string | URL;
20
+ loadWebLLM?: () => Promise<object>;
21
+ prebuilt?: boolean;
22
+ }): Promise<ScheduledEngine>;
23
+ import { ModelStore } from "./model-store.js";
24
+ import { ScheduledEngine } from "./engine.js";
types/engine/device.d.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @typedef {object} DeviceProbe
3
+ * @property {boolean} webgpu
4
+ * @property {string} [reason] why WebGPU is unusable, when it is
5
+ * @property {object} [adapter] vendor / architecture / device, where exposed
6
+ * @property {{shaderF16: boolean}} [features]
7
+ * @property {object} [limits]
8
+ * @property {boolean} [kvReuse] whether cross-turn KV reuse can be used here
9
+ * @property {{quota?: number, usage?: number, persisted?: boolean}} storage
10
+ * @property {number} [deviceMemoryGB] Chrome only; absent is not "small"
11
+ */
12
+ /**
13
+ * Reads what this machine will admit to. Never throws: an unusable device is a
14
+ * result, not an error — the caller's job is to explain it, not to crash.
15
+ *
16
+ * @returns {Promise<DeviceProbe>}
17
+ */
18
+ export function probeDevice(): Promise<DeviceProbe>;
19
+ /**
20
+ * Whether a model can run on a probed device.
21
+ *
22
+ * `blockers` mean it will not work; `warnings` mean it will work worse, or
23
+ * might not fit. The split matters: a caller should refuse to start on a
24
+ * blocker and merely inform on a warning, and conflating the two is how you end
25
+ * up either crashing or refusing to run something that would have been fine.
26
+ *
27
+ * @param {{model_id: string, vram_required_MB?: number, sizeBytes?: number}} model
28
+ * @param {DeviceProbe} probe
29
+ * @returns {{ok: boolean, blockers: Array<{code: string, message: string}>,
30
+ * warnings: Array<{code: string, message: string}>}}
31
+ */
32
+ export function canRun(model: {
33
+ model_id: string;
34
+ vram_required_MB?: number;
35
+ sizeBytes?: number;
36
+ }, probe: DeviceProbe): {
37
+ ok: boolean;
38
+ blockers: Array<{
39
+ code: string;
40
+ message: string;
41
+ }>;
42
+ warnings: Array<{
43
+ code: string;
44
+ message: string;
45
+ }>;
46
+ };
47
+ /**
48
+ * Rank a model list by what this device can actually run.
49
+ *
50
+ * The prebuilt list spans 239 MB to 31 GB, so "which model should I use" is the
51
+ * first question a developer has and the one they have least basis to answer.
52
+ * Runnable models come first, then fewest warnings; unrunnable ones are kept at
53
+ * the end carrying their reason rather than silently dropped, because "why
54
+ * can't I use that one" is the next question.
55
+ *
56
+ * **`prefer` is a real choice, not a default worth hiding.** Decode here is
57
+ * memory-bandwidth-bound — time per token scales with weight bytes (AI.md,
58
+ * "Why not llama.cpp/Ollama-class"), so the largest model that fits is also the
59
+ * slowest thing that fits. `"quality"` picks the biggest, `"speed"` the
60
+ * smallest. Neither is right for everyone, which is why it is a parameter.
61
+ *
62
+ * Vision models are excluded from a text ranking rather than merely deprioritised:
63
+ * a VLM answers text prompts perfectly well, but at several times the download
64
+ * for no benefit, so recommending one to a caller who did not ask is bad advice.
65
+ *
66
+ * @param {Array<object>} models `model_list` entries or registry records
67
+ * @param {{probe: DeviceProbe, maxVramMB?: number, needsVision?: boolean,
68
+ * prefer?: "quality" | "speed"}} opts
69
+ */
70
+ export function rankModels(models: Array<object>, { probe, maxVramMB, needsVision, prefer }?: {
71
+ probe: DeviceProbe;
72
+ maxVramMB?: number;
73
+ needsVision?: boolean;
74
+ prefer?: "quality" | "speed";
75
+ }): {
76
+ ok: boolean;
77
+ blockers: Array<{
78
+ code: string;
79
+ message: string;
80
+ }>;
81
+ warnings: Array<{
82
+ code: string;
83
+ message: string;
84
+ }>;
85
+ model: any;
86
+ }[];
87
+ /**
88
+ * @param {number} modelBytes
89
+ * @param {number} [bytesPerSecond] this machine's measured rate, when known
90
+ * @returns {{tokensPerSecond: number, basis: "measured" | "extrapolated",
91
+ * modelBytes: number, bytesPerSecond: number, reference?: string}}
92
+ */
93
+ export function projectSpeed(modelBytes: number, bytesPerSecond?: number): {
94
+ tokensPerSecond: number;
95
+ basis: "measured" | "extrapolated";
96
+ modelBytes: number;
97
+ bytesPerSecond: number;
98
+ reference?: string;
99
+ };
100
+ /**
101
+ * Decode throughput is memory bandwidth divided by weight bytes.
102
+ *
103
+ * This project measured the whole chain: decode reaches ~16 GB/s of the M4's
104
+ * ~120 GB/s, and the 1.06 GB build runs 16.6–18.1 tok/s — which is that
105
+ * quotient. So a projection needs one number, the *achieved* bandwidth, and
106
+ * everything else follows from model size.
107
+ *
108
+ * The constant below is that machine's figure and is only a starting point. The
109
+ * moment this engine has decoded anything it knows the real number for the
110
+ * machine it is on, and `ScheduledEngine.estimateSpeed()` switches to it — so
111
+ * this is a cold-start default, not a claim about anyone's hardware.
112
+ */
113
+ export const REFERENCE_DECODE_BYTES_PER_SECOND: 17000000000;
114
+ export const REFERENCE_DEVICE: "M4 MacBook Air (16 GB), Firefox";
115
+ export type DeviceProbe = {
116
+ webgpu: boolean;
117
+ /**
118
+ * why WebGPU is unusable, when it is
119
+ */
120
+ reason?: string;
121
+ /**
122
+ * vendor / architecture / device, where exposed
123
+ */
124
+ adapter?: object;
125
+ features?: {
126
+ shaderF16: boolean;
127
+ };
128
+ limits?: object;
129
+ /**
130
+ * whether cross-turn KV reuse can be used here
131
+ */
132
+ kvReuse?: boolean;
133
+ storage: {
134
+ quota?: number;
135
+ usage?: number;
136
+ persisted?: boolean;
137
+ };
138
+ /**
139
+ * Chrome only; absent is not "small"
140
+ */
141
+ deviceMemoryGB?: number;
142
+ };
types/engine/engine.d.ts ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export class ScheduledEngine {
2
+ /**
3
+ * @param {object} opts
4
+ * @param {ModelStore | import("./model-store.js").StorageAdapter} opts.store
5
+ * a ModelStore, or a bare StorageAdapter to wrap in one
6
+ * @param {string | URL} [opts.workerUrl]
7
+ * @param {() => Promise<object>} [opts.loadWebLLM]
8
+ * @param {boolean} [opts.prebuilt] expose WebLLM's 163 HuggingFace-hosted
9
+ * models, downloaded on first load. Default true. Set false for an
10
+ * offline-only build: `load()` then resolves registered models and nothing
11
+ * else, and an unknown id fails before the WebLLM bundle is even fetched.
12
+ */
13
+ constructor({ store, workerUrl, loadWebLLM, prebuilt }?: {
14
+ store: ModelStore | import("./model-store.js").StorageAdapter;
15
+ workerUrl?: string | URL;
16
+ loadWebLLM?: () => Promise<object>;
17
+ prebuilt?: boolean;
18
+ });
19
+ /** The ModelStore, so a host can drive the registry without a second handle. */
20
+ get store(): ModelStore;
21
+ /**
22
+ * `chat.completions.create()`, the WebLLM/OpenAI shape. See `chat.js`.
23
+ *
24
+ * Built once and cached: callers hold on to `engine.chat.completions` the way
25
+ * they did with WebLLM, and a fresh object each access would break that.
26
+ */
27
+ get chat(): any;
28
+ /**
29
+ * `environment()` — the read-only report, with `environment.measure()` on it.
30
+ *
31
+ * Cached like `chat` so a caller can hold on to it. Writes are `configure()`;
32
+ * see `environment.js` for why those are separate verbs.
33
+ */
34
+ get environment(): any;
35
+ get state(): {
36
+ status: string;
37
+ modelId: any;
38
+ progress: any;
39
+ error: any;
40
+ pool: {
41
+ size: number;
42
+ busy: number;
43
+ queued: number;
44
+ };
45
+ /** Model ids with a live pool. `modelId` is whichever of them is current. */
46
+ resident: any[];
47
+ /** Latest decode probe from an engine worker; see multistep.js. */
48
+ decode: any;
49
+ };
50
+ get hasWebGPU(): boolean;
51
+ /**
52
+ * @param {(state: object) => void} listener called immediately, then on change
53
+ * @returns {() => void} unsubscribe
54
+ */
55
+ subscribe(listener: (state: object) => void): () => void;
56
+ /** Model ids with a live pool right now. */
57
+ get resident(): string[];
58
+ /**
59
+ * Choose which resident model unaddressed requests go to.
60
+ *
61
+ * Distinct from `load()` on purpose: this is free and instant, because the
62
+ * weights are already up. `load()` is what costs.
63
+ */
64
+ use(modelId: any): {
65
+ status: string;
66
+ modelId: any;
67
+ progress: any;
68
+ error: any;
69
+ pool: {
70
+ size: number;
71
+ busy: number;
72
+ queued: number;
73
+ };
74
+ /** Model ids with a live pool. `modelId` is whichever of them is current. */
75
+ resident: any[];
76
+ /** Latest decode probe from an engine worker; see multistep.js. */
77
+ decode: any;
78
+ };
79
+ /** Registered models only — cheap, no bundle load. */
80
+ listModels(): Promise<any[]>;
81
+ /**
82
+ * Everything `load()` would accept, normalised: registered models first, then
83
+ * WebLLM's prebuilt list.
84
+ *
85
+ * Costs a WebLLM bundle fetch when `prebuilt` is on, because the list lives
86
+ * inside it. `listModels()` is the cheap call if you only care about what this
87
+ * app registered.
88
+ *
89
+ * @returns {Promise<Array<{modelId: string, source: string, model: string,
90
+ * contextWindow?: number, vramRequiredMB?: number, sizeBytes?: number}>>}
91
+ */
92
+ listAvailableModels(): Promise<Array<{
93
+ modelId: string;
94
+ source: string;
95
+ model: string;
96
+ contextWindow?: number;
97
+ vramRequiredMB?: number;
98
+ sizeBytes?: number;
99
+ }>>;
100
+ /**
101
+ * What this machine will admit to: WebGPU, adapter, `shader-f16`, the five
102
+ * limits that matter, storage quota. Cached — hardware does not change
103
+ * mid-session, and `requestAdapter()` is not free.
104
+ * @returns {Promise<import("./device.js").DeviceProbe>}
105
+ */
106
+ probe(): Promise<import("./device.js").DeviceProbe>;
107
+ /**
108
+ * Whether a model will run here, before anything is downloaded.
109
+ * @param {string} modelId
110
+ * @returns {Promise<{ok: boolean, blockers: Array<object>, warnings: Array<object>}>}
111
+ */
112
+ canRun(modelId: string): Promise<{
113
+ ok: boolean;
114
+ blockers: Array<object>;
115
+ warnings: Array<object>;
116
+ }>;
117
+ /**
118
+ * Which models this device should actually be asked to run, best first.
119
+ *
120
+ * The prebuilt list spans 239 MB to 31 GB; this is the answer to the first
121
+ * question a developer has and the one they have least basis to answer.
122
+ *
123
+ * @param {{maxVramMB?: number, needsVision?: boolean, needsToolCalling?: boolean,
124
+ * prefer?: "quality" | "speed"}} [opts]
125
+ */
126
+ recommendModels({ needsToolCalling, ...opts }?: {
127
+ maxVramMB?: number;
128
+ needsVision?: boolean;
129
+ needsToolCalling?: boolean;
130
+ prefer?: "quality" | "speed";
131
+ }): Promise<{
132
+ ok: boolean;
133
+ blockers: Array<{
134
+ code: string;
135
+ message: string;
136
+ }>;
137
+ warnings: Array<{
138
+ code: string;
139
+ message: string;
140
+ }>;
141
+ model: any;
142
+ }[]>;
143
+ /**
144
+ * Is this model's data on disk, so a load would need no network?
145
+ *
146
+ * Routes by who knows the keys. We wrote an injected model's artifacts and
147
+ * hold the manifest, so `verify()` answers exactly — including a `"partial"`
148
+ * verdict WebLLM cannot give. Everything else was fetched by WebLLM, which
149
+ * derives the keys as its loader did, so `hasModelInCache` is the answer.
150
+ *
151
+ * @returns {Promise<"cached" | "partial" | "absent">}
152
+ */
153
+ cacheState(modelId: any): Promise<"cached" | "partial" | "absent">;
154
+ /**
155
+ * Download a model into the cache **without building an engine**.
156
+ *
157
+ * For warming during onboarding: the bytes land while the user is still
158
+ * reading, and the later `load()` is a cache read. WebLLM cannot express this
159
+ * — `reload()` instantiates the wasm and needs a GPU before it fetches a
160
+ * single shard — so this is ours. See `prefetch.js` for the URL-derivation
161
+ * risk and the oracle that closes it.
162
+ *
163
+ * Needs no WebGPU at all, which is the other half of the point: an app can
164
+ * warm the cache on a machine it has not yet decided can run the model.
165
+ *
166
+ * @param {string} modelId
167
+ * @param {{signal?: AbortSignal, onProgress?: Function}} [opts]
168
+ */
169
+ prefetch(modelId: string, { signal, onProgress }?: {
170
+ signal?: AbortSignal;
171
+ onProgress?: Function;
172
+ }): Promise<{
173
+ modelId: string;
174
+ files: number;
175
+ bytes: number;
176
+ alreadyCached: boolean;
177
+ }>;
178
+ /**
179
+ * Free a model's bytes and **keep the registry entry**, so it stays a model
180
+ * this engine knows how to get again — the distinction from
181
+ * `store.remove()`, which forgets the URL a remote model would need.
182
+ *
183
+ * Delegates for remote and prebuilt models: `deleteModelAllInfoInCache` is
184
+ * WebLLM's, covers tensors + wasm + config, and is maintained upstream.
185
+ */
186
+ evict(modelId: any): Promise<{
187
+ freedKeys: number;
188
+ }>;
189
+ /**
190
+ * Forget a model entirely: free its bytes **and** drop the registry entry.
191
+ *
192
+ * `evict()` first, because that is what knows how to reach the bytes for each
193
+ * source — and it has to happen before the record is deleted, since for a
194
+ * remote model the record holds the only URL those bytes can be derived from.
195
+ * Deleting the entry first would strand them in Cache Storage permanently.
196
+ */
197
+ remove(modelId: any): Promise<{
198
+ freedKeys: number;
199
+ }>;
200
+ /**
201
+ * Projected decode throughput for a model, in tokens per second.
202
+ *
203
+ * `basis: "measured"` once anything has actually decoded on this machine —
204
+ * the engine then knows its own achieved bandwidth and every projection is
205
+ * device-specific. Before that, `basis: "extrapolated"` from a reference
206
+ * machine, which is a starting point and says so.
207
+ *
208
+ * Decode is memory-bandwidth-bound, so this is close to the whole story:
209
+ * time per token scales with weight bytes and little else.
210
+ *
211
+ * @param {string} [modelId] defaults to the current model
212
+ */
213
+ estimateSpeed(modelId?: string): Promise<{
214
+ tokensPerSecond: number;
215
+ basis: "measured" | "extrapolated";
216
+ modelBytes: number;
217
+ bytesPerSecond: number;
218
+ reference?: string;
219
+ modelId: string;
220
+ }>;
221
+ /**
222
+ * What is actually switched on right now, as opposed to what the device could
223
+ * support.
224
+ *
225
+ * The distinction matters for KV reuse in particular: `probe().kvReuse` is a
226
+ * device capability, but the decision is taken inside the engine worker,
227
+ * which is the authority. A caller debugging "why is my second turn slow"
228
+ * needs the decision, not the capability.
229
+ */
230
+ features(): Promise<{
231
+ kvReuse: boolean;
232
+ shaderF16: boolean;
233
+ decodeSteps: any;
234
+ multiStepDecoding: boolean;
235
+ engines: number;
236
+ maxEngines: any;
237
+ resident: string[];
238
+ computePassBatching: number;
239
+ decode: any;
240
+ }>;
241
+ /**
242
+ * Register a model. Two shapes, one call, and the difference is only where
243
+ * the bytes come from:
244
+ *
245
+ * ```js
246
+ * // fetched from a base URL you host — an HF repo, a CDN, your own origin
247
+ * await engine.registerModel({
248
+ * modelId: "my-model",
249
+ * model: "/models/my-model/",
250
+ * modelLib: "/models/my-model/my-model-webgpu.wasm",
251
+ * });
252
+ *
253
+ * // read off disk. No network connection at any point, ever.
254
+ * await engine.registerModel({ modelId: "my-model", files: entries });
255
+ * ```
256
+ *
257
+ * Both end up as one `model_list` entry that WebLLM's own loader resolves the
258
+ * same way — the local one only differs in that its base URL is minted on
259
+ * `.invalid` and its cache is populated before the loader ever looks.
260
+ *
261
+ * That origin is the *mechanism* of the offline guarantee, not a marker of
262
+ * it: `.invalid` is reserved by RFC 6761 and can never resolve, so there is
263
+ * no code path — no bug, no eviction, no future refactor — by which a local
264
+ * model reaches the network. It fails with a DNS error instead.
265
+ *
266
+ * `files` is `{ path, file }[]`; `filesFromDataTransfer` and
267
+ * `filesFromInput` build it from a drop event or a directory picker.
268
+ */
269
+ registerModel(spec: any): Promise<any>;
270
+ /**
271
+ * Bring a model up, whatever form you have it in.
272
+ *
273
+ * One entry point for all three routes, because from a caller's side "load a
274
+ * model" is one intention and having to know which of `load`,
275
+ * `registerModel` and `ingestModelFolder` to reach for is a decision the
276
+ * library can make for them:
277
+ *
278
+ * ```js
279
+ * load("Llama-3.2-1B-Instruct-q4f16_1-MLC") // prebuilt or registered id
280
+ * load("https://huggingface.co/mlc-ai/Foo", { modelLib }) // a URL you host
281
+ * load({ model, modelLib }) // the same, explicit
282
+ * load({ files }) | load(fileList) | load(dataTransfer) // a folder, no network
283
+ * ```
284
+ *
285
+ * `registerModel` and `ingestModelFolder` remain, unchanged, as the low-level
286
+ * primitives — this composes them rather than replacing them.
287
+ *
288
+ * **A URL always needs `modelLib`.** It is not guessed; see `sources.js` for
289
+ * the measurement behind that. **`defer: true`** registers the source and
290
+ * stops there, returning the record instead of the state — the manager's
291
+ * drop-now-load-later flow.
292
+ *
293
+ * Additive residency: a model already resident stays resident, so switching
294
+ * back to it costs nothing. That is only safe while the weights fit, so
295
+ * `keepResident: false` (the default) unloads whatever else is up first —
296
+ * the old single-model behaviour, and the safe one on a 16 GB machine.
297
+ * Pass `keepResident: true` to hold both, having checked the budget yourself
298
+ * with `canRun()`.
299
+ *
300
+ * @param {string | object} src an id, a URL, `{model, modelLib}`, or a folder
301
+ * @param {{keepResident?: boolean, signal?: AbortSignal, defer?: boolean,
302
+ * id?: string, modelLib?: string, modelType?: string, contextWindow?: number,
303
+ * vramRequiredMB?: number, onProgress?: Function}} [opts]
304
+ * @returns {Promise<object>} the engine state, or the registry record when `defer`
305
+ */
306
+ load(src: string | object, opts?: {
307
+ keepResident?: boolean;
308
+ signal?: AbortSignal;
309
+ defer?: boolean;
310
+ id?: string;
311
+ modelLib?: string;
312
+ modelType?: string;
313
+ contextWindow?: number;
314
+ vramRequiredMB?: number;
315
+ onProgress?: Function;
316
+ }): Promise<object>;
317
+ /**
318
+ * Let a model go, at one of two depths.
319
+ *
320
+ * ```js
321
+ * unload() // the current model's VRAM; cached bytes stay
322
+ * unload(id) // that model's VRAM
323
+ * unload(id, "cache") // and delete its cached bytes, keeping the registry entry
324
+ * ```
325
+ *
326
+ * At `"vram"` the bytes stay on disk, so loading it again costs no network —
327
+ * that is what makes switching back cheap, and the difference between this
328
+ * and `remove()`.
329
+ *
330
+ * **A bare `unload()` frees only the current model**, not every resident one.
331
+ * `unloadAll()` is the explicit form for that: freeing everything is the more
332
+ * destructive of the two readings and should have to be asked for by name.
333
+ *
334
+ * @param {string} [modelId] defaults to the current model. Omit both this and
335
+ * any resident model to no-op.
336
+ * @param {"vram"|"cache"} [level]
337
+ */
338
+ unload(modelId?: string, level?: "vram" | "cache"): Promise<{
339
+ status: string;
340
+ modelId: any;
341
+ progress: any;
342
+ error: any;
343
+ pool: {
344
+ size: number;
345
+ busy: number;
346
+ queued: number;
347
+ };
348
+ /** Model ids with a live pool. `modelId` is whichever of them is current. */
349
+ resident: any[];
350
+ /** Latest decode probe from an engine worker; see multistep.js. */
351
+ decode: any;
352
+ }>;
353
+ /** Unload every resident model. */
354
+ unloadAll(): Promise<{
355
+ status: string;
356
+ modelId: any;
357
+ progress: any;
358
+ error: any;
359
+ pool: {
360
+ size: number;
361
+ busy: number;
362
+ queued: number;
363
+ };
364
+ /** Model ids with a live pool. `modelId` is whichever of them is current. */
365
+ resident: any[];
366
+ /** Latest decode probe from an engine worker; see multistep.js. */
367
+ decode: any;
368
+ }>;
369
+ /**
370
+ * One completion.
371
+ *
372
+ * Named `complete` rather than `chat` so `engine.chat.completions.create()`
373
+ * — the WebLLM-shaped facade, Phase 2 — can take that name without a rename.
374
+ *
375
+ * @param {CompletionRequest} payload
376
+ * @param {(delta: string) => void} [onChunk] called per streamed text delta
377
+ * @returns {Promise<CompletionResult>}
378
+ */
379
+ complete(payload: CompletionRequest, onChunk?: (delta: string) => void): Promise<CompletionResult>;
380
+ /**
381
+ * `complete()`, but the callback receives WebLLM's chunk verbatim.
382
+ *
383
+ * Exists so the `chat.completions.create()` facade can pass chunks straight
384
+ * through instead of rebuilding an envelope — which is what dropped
385
+ * `tool_calls`, flattened `logprobs` and restamped `created`.
386
+ *
387
+ * @param {CompletionRequest} payload
388
+ * @param {(chunk: object) => void} [onRawChunk]
389
+ * @returns {Promise<CompletionResult & {toolCalls?: Array<object>}>}
390
+ */
391
+ completeRaw(payload: CompletionRequest, onRawChunk?: (chunk: object) => void): Promise<CompletionResult & {
392
+ toolCalls?: Array<object>;
393
+ }>;
394
+ /**
395
+ * One question, one answer, nothing kept.
396
+ *
397
+ * ```js
398
+ * const answer = await engine.ask("Summarise this in one line:\n" + doc);
399
+ * ```
400
+ *
401
+ * @param {string | Array<object>} input
402
+ * @param {object} [opts] anything `complete()` takes, plus `onDelta` to stream
403
+ * @returns {Promise<string>}
404
+ */
405
+ ask(input: string | Array<object>, opts?: object): Promise<string>;
406
+ /**
407
+ * A multi-turn conversation that keeps its own history.
408
+ *
409
+ * ```js
410
+ * const chat = engine.conversation({ system: "You are terse." });
411
+ * await chat.say("hello");
412
+ * await chat.say("and again?"); // remembers
413
+ * ```
414
+ *
415
+ * @param {object} [opts] `system`, `keep`, plus `complete()` defaults
416
+ */
417
+ conversation(opts?: object): {
418
+ readonly messages: any[];
419
+ readonly length: number;
420
+ say(content: string, onDelta?: (delta: string) => void): Promise<{
421
+ text: string;
422
+ finishReason: "length" | "stop" | "abort";
423
+ }>;
424
+ reset(): /*elided*/ any;
425
+ restore(messages: any): /*elided*/ any;
426
+ };
427
+ /**
428
+ * Ghost text, with the debounce/supersede/drop-if-stale discipline built in
429
+ * and the prompt left to you.
430
+ *
431
+ * ```js
432
+ * const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` });
433
+ * editor.on("input", async () => {
434
+ * const hint = await ghost.suggest(editor.textBefore());
435
+ * if (hint !== null) render(hint); // null means a newer keystroke won
436
+ * });
437
+ * editor.on("blur", () => ghost.cancel());
438
+ * ```
439
+ *
440
+ * @param {object} opts must include `prompt`
441
+ */
442
+ ghostText(opts: object): {
443
+ suggest(context: any): Promise<string | null>;
444
+ cancel(): number;
445
+ };
446
+ /**
447
+ * Embed text into vectors, through the same scheduler as everything else.
448
+ *
449
+ * ```js
450
+ * const [vector] = await engine.embed("a sentence", { modelId: EMBED_MODEL });
451
+ * const vectors = await engine.embed(["one", "two"], { modelId: EMBED_MODEL });
452
+ * ```
453
+ *
454
+ * **Needs an embedding model**, not a chat model — `snowflake-arctic-embed-*`
455
+ * in WebLLM's prebuilt list, from 239 MB. They are separate models, so this
456
+ * usually names `modelId` explicitly and holds it resident alongside a chat
457
+ * model with `load(id, { keepResident: true })`.
458
+ *
459
+ * Returns bare vectors because that is what a caller does arithmetic on; the
460
+ * OpenAI envelope is available as `embedRaw()` for anyone porting code that
461
+ * expects `data[].embedding`.
462
+ *
463
+ * **A running embedding cannot be interrupted.** Cancellation and preemption
464
+ * work by making a decode loop break out; one forward pass has no loop, so a
465
+ * `cancel()` that lands after the job starts marks it cancelled but does not
466
+ * stop it. Queued embeddings supersede and cancel normally. This is tolerable
467
+ * because an embedding is milliseconds where a completion is seconds — but it
468
+ * is a weaker guarantee than `complete()` gives, so it is stated rather than
469
+ * discovered.
470
+ *
471
+ * @param {string | string[]} input
472
+ * @param {{modelId?: string, task?: string, session?: string,
473
+ * priority?: string, preemptible?: boolean, id?: string}} [opts]
474
+ * @returns {Promise<number[][]>} one vector per input, in order
475
+ */
476
+ embed(input: string | string[], opts?: {
477
+ modelId?: string;
478
+ task?: string;
479
+ session?: string;
480
+ priority?: string;
481
+ preemptible?: boolean;
482
+ id?: string;
483
+ }): Promise<number[][]>;
484
+ /** `embed()`, returning WebLLM's OpenAI-shaped envelope untouched. */
485
+ embedRaw(input: any, opts?: {}): Promise<{
486
+ data: any;
487
+ usage: any;
488
+ }>;
489
+ /**
490
+ * Independent prompts, fanned across the pool. This is the only way to beat
491
+ * the ~10 tok/s single-stream ceiling, so anything embarrassingly parallel
492
+ * (translating a page, labelling a list) should arrive here rather than as a
493
+ * loop of `complete` calls.
494
+ *
495
+ * @param {CompletionRequest & {requests: Array<Partial<CompletionRequest>>}} payload
496
+ * @param {(item: BatchItem) => void} [onItem] called as each item lands
497
+ * @returns {Promise<Array<BatchItem>>}
498
+ */
499
+ batch(payload: CompletionRequest & {
500
+ requests: Array<Partial<CompletionRequest>>;
501
+ }, onItem?: (item: BatchItem) => void): Promise<Array<BatchItem>>;
502
+ /**
503
+ * Cancels by job id or by session key.
504
+ * @param {string} idOrSession
505
+ * @returns {number} how many jobs it stopped
506
+ */
507
+ cancel(idOrSession: string): number;
508
+ /**
509
+ * Applies a runtime knob to the running pool and persists it as the default.
510
+ *
511
+ * `decodeSteps` is the multi-step decode width (AI.md, "Multi-step decoding").
512
+ * It takes effect on the next burst — no reload — which is what makes sweeping
513
+ * it to find this machine's tick boundary cheap.
514
+ */
515
+ configure(patch: any): Promise<{
516
+ settings: {
517
+ decodeSteps: number;
518
+ engineCount: number;
519
+ };
520
+ engines: number;
521
+ }>;
522
+ #private;
523
+ }
524
+ /**
525
+ * The OpenAI generation fields WebLLM already speaks, plus the scheduling
526
+ * fields that are what this engine adds over calling WebLLM directly.
527
+ */
528
+ export type CompletionRequest = {
529
+ messages: Array<{
530
+ role: string;
531
+ content: string;
532
+ }>;
533
+ /**
534
+ * load this model first if it is not the live one
535
+ */
536
+ modelId?: string;
537
+ /**
538
+ * job id; also what `cancel(id)` takes
539
+ */
540
+ id?: string;
541
+ temperature?: number;
542
+ max_tokens?: number;
543
+ response_format?: object;
544
+ extra_body?: object;
545
+ /**
546
+ * the unit that owns an engine; a whole batch shares one
547
+ */
548
+ task?: string;
549
+ /**
550
+ * a later job with this key supersedes the earlier one
551
+ */
552
+ session?: string;
553
+ priority?: "interactive" | "normal" | "background";
554
+ /**
555
+ * may be interrupted by an `interactive` job
556
+ */
557
+ preemptible?: boolean;
558
+ };
559
+ export type CompletionResult = {
560
+ text: string;
561
+ usage?: object;
562
+ /**
563
+ * WebLLM's own values
564
+ */
565
+ finishReason?: "stop" | "length" | "abort";
566
+ /**
567
+ * superseded or explicitly cancelled
568
+ */
569
+ cancelled?: true;
570
+ /**
571
+ * an `interactive` job took the slot; `text` is partial
572
+ */
573
+ preempted?: true;
574
+ };
575
+ export type BatchItem = CompletionRequest & {
576
+ index: number;
577
+ engineIndex: number;
578
+ startedAt: number;
579
+ finishedAt: number;
580
+ error?: string;
581
+ };
582
+ import { ModelStore } from "./model-store.js";