File size: 15,000 Bytes
1944112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
/**
 * 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;
  }
}