File size: 8,051 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
/**
 * `prefetch(modelId)` β€” fill the cache without building an engine.
 *
 * The gap this closes: WebLLM only ever downloads a model as a side effect of
 * `reload()`, which instantiates the wasm and needs a GPU before it will fetch
 * a single weight shard. So "warm the cache during onboarding, decide about the
 * GPU later" is not expressible β€” and it is the thing an app wants to do while
 * the user is still reading the welcome screen.
 *
 * We already write these exact caches for injected models (`ingest.js`); this is
 * the same write plan with the bytes arriving over the network instead of off
 * disk.
 *
 * ## The one dangerous part, and how it is closed
 *
 * To fetch the artifacts ourselves we must know their URLs, which means
 * applying HuggingFace's `/resolve/main/` rule β€” the same rule
 * [ARCHIVE.md](../../ARCHIVE.md) records *removing* a copy of, and that Β§2a of
 * the roadmap says not to derive at registration time.
 *
 * Those decisions still hold and this does not contradict them: they are about
 * not deriving a URL that WebLLM will derive again at load, which double-applies
 * it. Here WebLLM is not in the loop at all β€” we are the loader β€” so there is
 * nothing to double-apply.
 *
 * What makes it *safe* is that we do not trust our own derivation. A key that is
 * off by one character writes a cache WebLLM's loader will never look in, and
 * the symptom is the worst kind: prefetch reports success and the user downloads
 * the model twice. So every prefetch ends by asking **WebLLM's own
 * `hasModelInCache`** β€” which derives the URL through the very function we are
 * mirroring β€” whether the model is really there. If it says no, this throws
 * instead of claiming success.
 *
 * `webllm-contract.test.mjs` additionally pins the rule against the bundle, so
 * an upstream change to the URL scheme fails a test rather than a user's
 * download.
 */
import { ERROR, EngineError } from "./errors.js";
import {
  CHAT_CONFIG,
  CONTENT_TYPES,
  LEGACY_TENSOR_MANIFEST,
  TENSOR_MANIFEST,
} from "./ingest.js";
import { CACHE_CONFIG, CACHE_MODEL, CACHE_WASM, isInjected } from "./model-store.js";

/**
 * WebLLM's `cleanModelUrl`, mirrored deliberately.
 *
 * Kept character-for-character with the bundle's version (see the contract
 * test) because the whole point is to produce the same cache keys its loader
 * will look for.
 */
export function resolveModelUrl(modelUrl) {
  let url = modelUrl + (modelUrl.endsWith("/") ? "" : "/");
  if (!url.match(/.+\/resolve\/.+\//)) url += "resolve/main/";
  return new URL(url).href;
}

/**
 * @param {object} opts
 * @param {string} opts.modelId
 * @param {object} opts.record the merged app-config entry: `model`, `model_lib`
 * @param {(p: {phase: string, done: number, total: number, label: string}) => void} [opts.onProgress]
 * @param {AbortSignal} [opts.signal]
 * @param {(url: string, init?: object) => Promise<Response>} [opts.fetchImpl]
 * @returns {Promise<{modelId: string, files: number, bytes: number, alreadyCached: boolean}>}
 */
export async function prefetchModel({
  modelId,
  record,
  onProgress = () => {},
  signal,
  fetchImpl = globalThis.fetch,
}) {
  if (!record?.model_lib) {
    throw new EngineError(
      ERROR.BAD_REQUEST,
      `"${modelId}" has no \`model_lib\`, so there is nothing to prefetch from. ` +
        "A remote source needs one; see load(url, { modelLib }).",
      { modelId },
    );
  }

  const base = resolveModelUrl(record.model);
  const get = async (url, what) => {
    if (signal?.aborted) throw aborted(modelId);
    const res = await fetchImpl(url, signal ? { signal } : undefined).catch((err) => {
      throw new EngineError(ERROR.GENERATION_FAILED, `Prefetch could not reach ${what}: ${err?.message ?? err}`, {
        modelId,
        url,
      });
    });
    if (!res.ok) {
      throw new EngineError(
        ERROR.UNKNOWN_MODEL,
        `Prefetch got ${res.status} for ${what} at ${url}. ` +
          "Check the model's base URL β€” a 404 here usually means the id or the URL is wrong.",
        { modelId, url, status: res.status },
      );
    }
    return res;
  };

  // The config first: it names the tokenizer files, so the plan cannot be built
  // without it. Same order `reload()` uses.
  onProgress({ phase: "manifest", done: 0, total: 1, label: CHAT_CONFIG });
  const configRes = await get(base + CHAT_CONFIG, CHAT_CONFIG);
  const configBytes = await configRes.arrayBuffer();
  const chatConfig = parseJson(configBytes, CHAT_CONFIG, modelId);

  // `tensor-cache.json`, falling back to the legacy name, exactly as ingest does.
  let manifestName = TENSOR_MANIFEST;
  let manifestRes = await fetchImpl(base + TENSOR_MANIFEST, signal ? { signal } : undefined).catch(() => null);
  if (!manifestRes?.ok) {
    manifestName = LEGACY_TENSOR_MANIFEST;
    manifestRes = await get(base + LEGACY_TENSOR_MANIFEST, "the weight index");
  }
  const manifestBytes = await manifestRes.arrayBuffer();
  const manifest = parseJson(manifestBytes, manifestName, modelId);

  const shards = (manifest.records ?? []).map((r) => r.dataPath).filter(Boolean);
  if (shards.length === 0) {
    throw new EngineError(
      ERROR.UNKNOWN_MODEL,
      `${manifestName} at ${base} lists no weight shards, so this is not an MLC model directory.`,
      { modelId, url: base + manifestName },
    );
  }

  const tokenizers = (Array.isArray(chatConfig.tokenizer_files) ? chatConfig.tokenizer_files : []).filter(
    (n) => n === "tokenizer.json" || n === "tokenizer.model",
  );

  const plan = [
    { scope: CACHE_CONFIG, url: base + CHAT_CONFIG, body: configBytes, type: CONTENT_TYPES.json },
    { scope: CACHE_MODEL, url: base + manifestName, body: manifestBytes, type: CONTENT_TYPES.json },
    ...tokenizers.map((name) => ({
      scope: CACHE_MODEL,
      url: base + name,
      type: name.endsWith(".json") ? CONTENT_TYPES.json : CONTENT_TYPES.bin,
    })),
    ...shards.map((p) => ({ scope: CACHE_MODEL, url: new URL(p, base).href, type: CONTENT_TYPES.bin })),
    // Verbatim, never derived β€” `model_lib` is a literal URL on the record and
    // is not even on the same origin as the weights for any prebuilt model.
    { scope: CACHE_WASM, url: record.model_lib, type: CONTENT_TYPES.wasm },
  ];

  const openCaches = new Map();
  const cacheFor = async (scope) => {
    if (!openCaches.has(scope)) openCaches.set(scope, await caches.open(scope));
    return openCaches.get(scope);
  };

  let bytes = 0;
  let done = 0;
  for (const item of plan) {
    if (signal?.aborted) throw aborted(modelId);
    const cache = await cacheFor(item.scope);
    onProgress({ phase: "downloading", done, total: plan.length, label: basename(item.url) });

    // Skip what is already there: a resumed prefetch should cost only the
    // remainder, the same way a resumed `load()` does.
    if (item.body === undefined && (await cache.match(new Request(item.url)))) {
      done += 1;
      continue;
    }

    const body = item.body ?? (await (await get(item.url, basename(item.url))).arrayBuffer());
    bytes += body.byteLength;
    await cache.put(
      new Request(item.url),
      new Response(body, { status: 200, headers: { "Content-Type": item.type } }),
    );
    done += 1;
  }
  onProgress({ phase: "downloading", done, total: plan.length, label: "done" });

  return { modelId, files: plan.length, bytes, alreadyCached: false };
}

const aborted = (modelId) =>
  new EngineError(ERROR.ABORTED, `Prefetch of "${modelId}" was aborted.`, { modelId });

function parseJson(buffer, what, modelId) {
  try {
    return JSON.parse(new TextDecoder().decode(buffer));
  } catch {
    throw new EngineError(
      ERROR.UNKNOWN_MODEL,
      `${what} for "${modelId}" is not valid JSON β€” the URL is probably not an MLC model directory.`,
      { modelId, what },
    );
  }
}

const basename = (url) => url.split("/").pop() || url;

/** Only for injected models: they are in the cache before they are ever registered. */
export const isAlreadyLocal = (record) => isInjected(record);