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);
|