everything-webgpu / src /engine /model-store.js
nyaaorick's picture
feat: publish everything-webgpu package, engine source and documentation
1944112 verified
Raw
History Blame Contribute Delete
15 kB
/**
* The model registry, and the Cache Storage layout WebLLM expects.
*
* A model reaches the engine by one of three routes. They differ only in where
* the weights are fetched from; everything downstream is identical, because all
* three end up as one `model_list` entry WebLLM's own loader resolves.
*
* prebuilt one of the 163 entries in WebLLM's `prebuiltAppConfig`, on
* HuggingFace. Nothing to register: `load("Llama-3.2-1B-Instruct-
* q4f16_1-MLC")` just works.
* remote `registerModel({ model, modelLib })` with any base URL — an HF
* repo, your own CDN, a path on your own origin, localhost. This
* is how a developer points the engine at weights they host.
* injected `ingestModelFolder()` writes a local folder straight into Cache
* Storage. No network at any point, for offline or private builds.
*
* The injected route mints a synthetic https base URL and pre-populates the
* exact cache scopes/keys WebLLM's loader would have populated from the
* network, so `reload()` finds every artifact already cached and issues zero
* requests. That origin is `.invalid` on purpose: it can never resolve, so an
* injected model whose cache was evicted fails loudly instead of quietly
* pulling a gigabyte off the network. Enabling downloads for the other two
* routes cannot weaken that guarantee.
*
* Registry and settings hang off a `ModelStore` holding an injected
* `StorageAdapter`. That is the whole reason this file is no longer
* extension-bound: `browser.storage.local` was the only WebExtension API in the
* engine core outside the router.
*
* Cache Storage is deliberately *not* injected. `caches` exists in every secure
* context, and the cache keys are the contract with WebLLM's loader — putting
* an abstraction over them would hide the one thing that has to stay exact.
*
* Cache scopes (must stay in sync with @mlc-ai/web-llm):
* webllm/config -> <base>mlc-chat-config.json
* webllm/model -> <base>tensor-cache.json, tokenizer file, every shard
* webllm/wasm -> <base><lib>.wasm
*/
import { ERROR, EngineError } from "./errors.js";
export const CACHE_CONFIG = "webllm/config";
export const CACHE_MODEL = "webllm/model";
export const CACHE_WASM = "webllm/wasm";
/**
* `.invalid` is reserved by RFC 6761 and can never resolve, so a bug that skips
* the cache surfaces as a DNS failure instead of a silent download.
* The `/resolve/main/` suffix makes WebLLM's `cleanModelUrl()` a no-op.
*/
const VIRTUAL_ORIGIN = "https://local-model.invalid";
const STORAGE_KEY = "models";
const SETTINGS_KEY = "settings";
export const DEFAULT_SETTINGS = {
/** Empty list = every installed extension may call the API. Wire adapter only. */
allowedExternalIds: [],
/**
* Engines held in the pool. Each is a full copy of the weights in VRAM and a
* full load, but concurrent generations each get their own ~10 tok/s, so this
* is the only dial that raises total throughput. 2 is the smallest number
* that delivers any parallelism at all.
*/
engineCount: 2,
/**
* Forward steps per GPU->CPU sync (vLLM's `--num-scheduler-steps`). Decode is
* sync-bound, not compute-bound, so this is the only dial that raises
* *single-stream* throughput — `engineCount` raises aggregate throughput.
*
* 15 is vLLM's documented cap and this engine's default. Unlike vLLM the
* win here is quantized by Firefox's 100 ms poll, so the best value is the
* largest K whose burst still fits inside one tick, and it shrinks as the
* model grows. See src/engine/multistep.js and `npm run e2e -- --steps`.
*/
decodeSteps: 15,
/**
* `buildParams` puts this on every request, so it shadows whatever
* `mlc-chat-config.json` ships as the model's own default — unlike `top_p`,
* which is never injected and so comes from the model. 0.6 is what the
* Qwen3.8-2B-Distill card asks for; reasoning models in this class are prone
* to repetition loops when decoding is too close to greedy.
*/
temperature: 0.6,
maxTokens: 1024,
systemPrompt: "",
};
/**
* Make a base URL absolute, at registration rather than at load.
*
* WebLLM's `cleanModelUrl` ends in `new URL(url)` with no base, so it throws on
* a relative path — `/models/my-model/` fails deep inside the loader, long
* after the caller could tell why. Resolving here means a relative path works
* as documented, and a context with no page URL to resolve against says so
* immediately instead of at load time.
*/
function absolutize(url, field) {
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url;
const base = globalThis.location?.href;
if (!base) {
throw new EngineError(
ERROR.BAD_REQUEST,
`\`${field}\` is relative ("${url}") and this context has no page URL to resolve it against. ` +
"Pass an absolute URL.",
{ field, value: url },
);
}
return new URL(url, base).href;
}
export function baseUrlFor(modelId) {
return `${VIRTUAL_ORIGIN}/${encodeURIComponent(modelId)}/resolve/main/`;
}
/** Every cache key a record claims, keyed by cache scope. */
export function groupKeysByScope(record) {
return {
[CACHE_CONFIG]: record.keys?.[CACHE_CONFIG] ?? [],
[CACHE_MODEL]: record.keys?.[CACHE_MODEL] ?? [],
[CACHE_WASM]: record.keys?.[CACHE_WASM] ?? [],
};
}
/**
* WebLLM's own `ModelType` enum, which it reads off the `model_list` entry.
*
* This matters for one reason: WebLLM refuses image content on anything not
* marked `VLM` (`UserMessageContentErrorForNonVLM`). It cannot be inferred —
* `mlc-chat-config.json` carries the architecture name, not this — so a
* locally compiled vision model has to declare it or every image is rejected
* with a confusing error.
*/
export const MODEL_TYPE = { llm: 0, embedding: 1, vlm: 2 };
/** Accepts `"vlm"`, `MODEL_TYPE.vlm`, or nothing. */
export function toModelType(value) {
if (value === undefined || value === null) return undefined;
if (typeof value === "number") return value;
const known = MODEL_TYPE[String(value).toLowerCase()];
if (known === undefined) {
throw new EngineError(
ERROR.BAD_REQUEST,
`Unknown modelType "${value}". Expected one of: ${Object.keys(MODEL_TYPE).join(", ")}.`,
{ modelType: value },
);
}
return known;
}
/** How a record's weights are obtained. See the header. */
export const SOURCE = {
PREBUILT: "prebuilt",
REMOTE: "remote",
INJECTED: "injected",
};
/**
* Whether this record's bytes live in Cache Storage and nowhere else.
*
* The distinction that matters: an injected model that loses its cache is
* unrecoverable and must be re-ingested, so `verify()` gates its load. A remote
* or prebuilt one just re-downloads, so eviction is a slow load, not an error.
*/
export const isInjected = (record) => record?.source === SOURCE.INJECTED;
function toModelListEntry(record) {
return {
model: record.model,
model_id: record.model_id,
model_lib: record.model_lib,
// Carried through, or WebLLM treats a locally registered VLM as text-only.
...(record.model_type !== undefined ? { model_type: record.model_type } : {}),
...(record.overrides ? { overrides: record.overrides } : {}),
...(record.vram_required_MB ? { vram_required_MB: record.vram_required_MB } : {}),
};
}
/**
* Shape WebLLM's `appConfig` from the registry, optionally over its own
* prebuilt list.
*
* Registered records win on a model_id collision, so a developer can shadow a
* prebuilt entry — point `Llama-3.2-1B-Instruct-q4f16_1-MLC` at their own
* mirror, say — without renaming it and breaking their callers.
*
* @param {Array<object>} models registered records
* @param {{model_list: Array<object>} | null} [prebuilt] WebLLM's `prebuiltAppConfig`
*/
export function toAppConfig(models, prebuilt = null) {
const own = models.map(toModelListEntry);
const owned = new Set(own.map((e) => e.model_id));
const rest = (prebuilt?.model_list ?? []).filter((e) => !owned.has(e.model_id));
return { model_list: [...own, ...rest], useIndexedDBCache: false };
}
export function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
/**
* @typedef {object} StorageAdapter
* @property {(key: string | string[]) => Promise<object>} get
* @property {(items: object) => Promise<void>} set
*
* Two methods, deliberately the exact shape of `browser.storage.local`, so the
* WebExtension adapter is a passthrough rather than a translation layer. See
* `src/adapters/` for the three implementations.
*/
export class ModelStore {
/** @type {StorageAdapter} */
#storage;
/** @param {StorageAdapter} storage */
constructor(storage) {
if (!storage?.get || !storage?.set) {
throw new EngineError(ERROR.BAD_REQUEST, "ModelStore needs a StorageAdapter with `get(key)` and `set(obj)`.");
}
this.#storage = storage;
}
/** @returns {Promise<Array<object>>} registered model records, newest first. */
async list() {
const { [STORAGE_KEY]: models } = await this.#storage.get(STORAGE_KEY);
return Array.isArray(models) ? models : [];
}
async get(modelId) {
return (await this.list()).find((m) => m.model_id === modelId);
}
/**
* Register weights the engine should fetch rather than find already cached.
*
* This is the whole "developer configures a read path" surface: `model` is a
* base URL and nothing more, so an HF repo, your own CDN, a folder served off
* your own origin and a localhost dev server are all the same call.
*
* ```js
* await store.registerModel({
* modelId: "Qwen3.8-2B-q4f16_1-MLC",
* model: "/models/Qwen3.8-2B-q4f16_1-MLC/",
* modelLib: "/models/Qwen3.8-2B-q4f16_1-MLC/Qwen3.8-2B-q4f16_1-webgpu.wasm",
* });
* ```
*
* No validation of the URLs happens here — there is nothing to validate
* without fetching, and WebLLM's loader reports a bad base URL far better
* than a HEAD request would. Contrast `ingestModelFolder`, which validates
* exhaustively because it is about to write a gigabyte.
*/
async registerModel({ modelId, model, modelLib, contextWindow, vramRequiredMB, modelType }) {
const missing = [
["modelId", modelId],
["model", model],
["modelLib", modelLib],
].filter(([, v]) => !v);
if (missing.length) {
throw new EngineError(
ERROR.BAD_REQUEST,
`registerModel needs ${missing.map(([k]) => `\`${k}\``).join(", ")}. ` +
"Pass `files` instead to register a local folder.",
{ missing: missing.map(([k]) => k) },
);
}
return this.save({
model_id: modelId,
model: absolutize(model, "model"),
model_lib: absolutize(modelLib, "modelLib"),
source: SOURCE.REMOTE,
...(toModelType(modelType) !== undefined ? { model_type: toModelType(modelType) } : {}),
...(contextWindow > 0 ? { overrides: { context_window_size: contextWindow } } : {}),
...(vramRequiredMB > 0 ? { vram_required_MB: vramRequiredMB } : {}),
addedAt: new Date().toISOString(),
});
}
async save(record) {
const models = (await this.list()).filter((m) => m.model_id !== record.model_id);
models.unshift(record);
await this.#storage.set({ [STORAGE_KEY]: models });
return record;
}
/**
* Frees an **injected** model's bytes; keeps the registry entry.
*
* Scoped to injected models deliberately. We wrote those artifacts, so we
* hold the only manifest of them — which also makes this stronger than
* WebLLM's equivalent *for this case*: `deleteModelAllInfoInCache` reads
* `tensor-cache.json` to enumerate shards, so it cannot clean up after that
* file has itself been evicted. An explicit key list has no such failure.
*
* Remote and prebuilt models are WebLLM's to delete — it fetched them and
* derives their keys exactly as its loader did. `ScheduledEngine.evict()`
* routes to whichever owns the model.
*
* @returns {Promise<{freedKeys: number}>}
*/
async evictInjected(modelId) {
const record = await this.get(modelId);
if (!record) throw new EngineError(ERROR.UNKNOWN_MODEL, `Unknown model "${modelId}"`, { modelId });
let freedKeys = 0;
for (const [scope, urls] of Object.entries(groupKeysByScope(record))) {
if (urls.length === 0) continue;
const cache = await caches.open(scope);
for (const url of urls) if (await cache.delete(url)) freedKeys += 1;
}
return { freedKeys };
}
/**
* The registry-only primitive: drops the entry, plus the cache keys an
* **injected** record lists.
*
* A remote record claims no keys, so this frees nothing for it — WebLLM
* downloaded those bytes and derives their names. Use
* `ScheduledEngine.remove()` for a full teardown; calling this directly on a
* remote model leaks its shards, because deleting the record also destroys
* the only record of where they came from.
*/
async remove(modelId) {
const record = await this.get(modelId);
if (!record) throw new EngineError(ERROR.UNKNOWN_MODEL, `Unknown model "${modelId}"`, { modelId });
for (const [scope, urls] of Object.entries(groupKeysByScope(record))) {
const cache = await caches.open(scope);
await Promise.all(urls.map((url) => cache.delete(url)));
}
const models = (await this.list()).filter((m) => m.model_id !== modelId);
await this.#storage.set({ [STORAGE_KEY]: models });
}
/**
* Confirms the caches still hold everything the record promised.
*
* Storage eviction is silent, so this is what stands between a stale registry
* entry and WebLLM trying to fetch `local-model.invalid` mid-load. Inside a
* WebExtension with `unlimitedStorage` this was a defensive check; on an
* ordinary page origin, where a multi-GB model is evictable unless
* `navigator.storage.persist()` was granted, it is a core mechanism.
*
* @returns {Promise<{ok: boolean, missing: string[]}>}
*/
async verify(record) {
const missing = [];
for (const [scope, urls] of Object.entries(groupKeysByScope(record))) {
if (urls.length === 0) continue;
const cache = await caches.open(scope);
const present = new Set((await cache.keys()).map((req) => req.url));
for (const url of urls) if (!present.has(url)) missing.push(url);
}
return { ok: missing.length === 0, missing };
}
async getSettings() {
const { [SETTINGS_KEY]: settings } = await this.#storage.get(SETTINGS_KEY);
return { ...DEFAULT_SETTINGS, ...(settings ?? {}) };
}
async setSettings(patch) {
const next = { ...(await this.getSettings()), ...patch };
await this.#storage.set({ [SETTINGS_KEY]: next });
return next;
}
}