export class ScheduledEngine { /** * @param {object} opts * @param {ModelStore | import("./model-store.js").StorageAdapter} opts.store * a ModelStore, or a bare StorageAdapter to wrap in one * @param {string | URL} [opts.workerUrl] * @param {() => Promise} [opts.loadWebLLM] * @param {boolean} [opts.prebuilt] expose WebLLM's 163 HuggingFace-hosted * models, downloaded on first load. Default true. Set false for an * offline-only build: `load()` then resolves registered models and nothing * else, and an unknown id fails before the WebLLM bundle is even fetched. */ constructor({ store, workerUrl, loadWebLLM, prebuilt }?: { store: ModelStore | import("./model-store.js").StorageAdapter; workerUrl?: string | URL; loadWebLLM?: () => Promise; prebuilt?: boolean; }); /** The ModelStore, so a host can drive the registry without a second handle. */ get store(): ModelStore; /** * `chat.completions.create()`, the WebLLM/OpenAI shape. See `chat.js`. * * Built once and cached: callers hold on to `engine.chat.completions` the way * they did with WebLLM, and a fresh object each access would break that. */ get chat(): any; /** * `environment()` — the read-only report, with `environment.measure()` on it. * * Cached like `chat` so a caller can hold on to it. Writes are `configure()`; * see `environment.js` for why those are separate verbs. */ get environment(): any; get state(): { status: string; modelId: any; progress: any; error: any; pool: { size: number; busy: number; queued: number; }; /** Model ids with a live pool. `modelId` is whichever of them is current. */ resident: any[]; /** Latest decode probe from an engine worker; see multistep.js. */ decode: any; }; get hasWebGPU(): boolean; /** * @param {(state: object) => void} listener called immediately, then on change * @returns {() => void} unsubscribe */ subscribe(listener: (state: object) => void): () => void; /** Model ids with a live pool right now. */ get resident(): string[]; /** * Choose which resident model unaddressed requests go to. * * Distinct from `load()` on purpose: this is free and instant, because the * weights are already up. `load()` is what costs. */ use(modelId: any): { status: string; modelId: any; progress: any; error: any; pool: { size: number; busy: number; queued: number; }; /** Model ids with a live pool. `modelId` is whichever of them is current. */ resident: any[]; /** Latest decode probe from an engine worker; see multistep.js. */ decode: any; }; /** Registered models only — cheap, no bundle load. */ listModels(): Promise; /** * Everything `load()` would accept, normalised: registered models first, then * WebLLM's prebuilt list. * * Costs a WebLLM bundle fetch when `prebuilt` is on, because the list lives * inside it. `listModels()` is the cheap call if you only care about what this * app registered. * * @returns {Promise>} */ listAvailableModels(): Promise>; /** * What this machine will admit to: WebGPU, adapter, `shader-f16`, the five * limits that matter, storage quota. Cached — hardware does not change * mid-session, and `requestAdapter()` is not free. * @returns {Promise} */ probe(): Promise; /** * Whether a model will run here, before anything is downloaded. * @param {string} modelId * @returns {Promise<{ok: boolean, blockers: Array, warnings: Array}>} */ canRun(modelId: string): Promise<{ ok: boolean; blockers: Array; warnings: Array; }>; /** * Which models this device should actually be asked to run, best first. * * The prebuilt list spans 239 MB to 31 GB; this is the answer to the first * question a developer has and the one they have least basis to answer. * * @param {{maxVramMB?: number, needsVision?: boolean, needsToolCalling?: boolean, * prefer?: "quality" | "speed"}} [opts] */ recommendModels({ needsToolCalling, ...opts }?: { maxVramMB?: number; needsVision?: boolean; needsToolCalling?: boolean; prefer?: "quality" | "speed"; }): Promise<{ ok: boolean; blockers: Array<{ code: string; message: string; }>; warnings: Array<{ code: string; message: string; }>; model: any; }[]>; /** * Is this model's data on disk, so a load would need no network? * * Routes by who knows the keys. We wrote an injected model's artifacts and * hold the manifest, so `verify()` answers exactly — including a `"partial"` * verdict WebLLM cannot give. Everything else was fetched by WebLLM, which * derives the keys as its loader did, so `hasModelInCache` is the answer. * * @returns {Promise<"cached" | "partial" | "absent">} */ cacheState(modelId: any): Promise<"cached" | "partial" | "absent">; /** * Download a model into the cache **without building an engine**. * * For warming during onboarding: the bytes land while the user is still * reading, and the later `load()` is a cache read. WebLLM cannot express this * — `reload()` instantiates the wasm and needs a GPU before it fetches a * single shard — so this is ours. See `prefetch.js` for the URL-derivation * risk and the oracle that closes it. * * Needs no WebGPU at all, which is the other half of the point: an app can * warm the cache on a machine it has not yet decided can run the model. * * @param {string} modelId * @param {{signal?: AbortSignal, onProgress?: Function}} [opts] */ prefetch(modelId: string, { signal, onProgress }?: { signal?: AbortSignal; onProgress?: Function; }): Promise<{ modelId: string; files: number; bytes: number; alreadyCached: boolean; }>; /** * Free a model's bytes and **keep the registry entry**, so it stays a model * this engine knows how to get again — the distinction from * `store.remove()`, which forgets the URL a remote model would need. * * Delegates for remote and prebuilt models: `deleteModelAllInfoInCache` is * WebLLM's, covers tensors + wasm + config, and is maintained upstream. */ evict(modelId: any): Promise<{ freedKeys: number; }>; /** * Forget a model entirely: free its bytes **and** drop the registry entry. * * `evict()` first, because that is what knows how to reach the bytes for each * source — and it has to happen before the record is deleted, since for a * remote model the record holds the only URL those bytes can be derived from. * Deleting the entry first would strand them in Cache Storage permanently. */ remove(modelId: any): Promise<{ freedKeys: number; }>; /** * Projected decode throughput for a model, in tokens per second. * * `basis: "measured"` once anything has actually decoded on this machine — * the engine then knows its own achieved bandwidth and every projection is * device-specific. Before that, `basis: "extrapolated"` from a reference * machine, which is a starting point and says so. * * Decode is memory-bandwidth-bound, so this is close to the whole story: * time per token scales with weight bytes and little else. * * @param {string} [modelId] defaults to the current model */ estimateSpeed(modelId?: string): Promise<{ tokensPerSecond: number; basis: "measured" | "extrapolated"; modelBytes: number; bytesPerSecond: number; reference?: string; modelId: string; }>; /** * What is actually switched on right now, as opposed to what the device could * support. * * The distinction matters for KV reuse in particular: `probe().kvReuse` is a * device capability, but the decision is taken inside the engine worker, * which is the authority. A caller debugging "why is my second turn slow" * needs the decision, not the capability. */ features(): Promise<{ kvReuse: boolean; shaderF16: boolean; decodeSteps: any; multiStepDecoding: boolean; engines: number; maxEngines: any; resident: string[]; computePassBatching: number; decode: any; }>; /** * Register a model. Two shapes, one call, and the difference is only where * the bytes come from: * * ```js * // fetched from a base URL you host — an HF repo, a CDN, your own origin * await engine.registerModel({ * modelId: "my-model", * model: "/models/my-model/", * modelLib: "/models/my-model/my-model-webgpu.wasm", * }); * * // read off disk. No network connection at any point, ever. * await engine.registerModel({ modelId: "my-model", files: entries }); * ``` * * Both end up as one `model_list` entry that WebLLM's own loader resolves the * same way — the local one only differs in that its base URL is minted on * `.invalid` and its cache is populated before the loader ever looks. * * That origin is the *mechanism* of the offline guarantee, not a marker of * it: `.invalid` is reserved by RFC 6761 and can never resolve, so there is * no code path — no bug, no eviction, no future refactor — by which a local * model reaches the network. It fails with a DNS error instead. * * `files` is `{ path, file }[]`; `filesFromDataTransfer` and * `filesFromInput` build it from a drop event or a directory picker. */ registerModel(spec: any): Promise; /** * Bring a model up, whatever form you have it in. * * One entry point for all three routes, because from a caller's side "load a * model" is one intention and having to know which of `load`, * `registerModel` and `ingestModelFolder` to reach for is a decision the * library can make for them: * * ```js * load("Llama-3.2-1B-Instruct-q4f16_1-MLC") // prebuilt or registered id * load("https://huggingface.co/mlc-ai/Foo", { modelLib }) // a URL you host * load({ model, modelLib }) // the same, explicit * load({ files }) | load(fileList) | load(dataTransfer) // a folder, no network * ``` * * `registerModel` and `ingestModelFolder` remain, unchanged, as the low-level * primitives — this composes them rather than replacing them. * * **A URL always needs `modelLib`.** It is not guessed; see `sources.js` for * the measurement behind that. **`defer: true`** registers the source and * stops there, returning the record instead of the state — the manager's * drop-now-load-later flow. * * Additive residency: a model already resident stays resident, so switching * back to it costs nothing. That is only safe while the weights fit, so * `keepResident: false` (the default) unloads whatever else is up first — * the old single-model behaviour, and the safe one on a 16 GB machine. * Pass `keepResident: true` to hold both, having checked the budget yourself * with `canRun()`. * * @param {string | object} src an id, a URL, `{model, modelLib}`, or a folder * @param {{keepResident?: boolean, signal?: AbortSignal, defer?: boolean, * id?: string, modelLib?: string, modelType?: string, contextWindow?: number, * vramRequiredMB?: number, onProgress?: Function}} [opts] * @returns {Promise} the engine state, or the registry record when `defer` */ load(src: string | object, opts?: { keepResident?: boolean; signal?: AbortSignal; defer?: boolean; id?: string; modelLib?: string; modelType?: string; contextWindow?: number; vramRequiredMB?: number; onProgress?: Function; }): Promise; /** * Let a model go, at one of two depths. * * ```js * unload() // the current model's VRAM; cached bytes stay * unload(id) // that model's VRAM * unload(id, "cache") // and delete its cached bytes, keeping the registry entry * ``` * * At `"vram"` the bytes stay on disk, so loading it again costs no network — * that is what makes switching back cheap, and the difference between this * and `remove()`. * * **A bare `unload()` frees only the current model**, not every resident one. * `unloadAll()` is the explicit form for that: freeing everything is the more * destructive of the two readings and should have to be asked for by name. * * @param {string} [modelId] defaults to the current model. Omit both this and * any resident model to no-op. * @param {"vram"|"cache"} [level] */ unload(modelId?: string, level?: "vram" | "cache"): Promise<{ status: string; modelId: any; progress: any; error: any; pool: { size: number; busy: number; queued: number; }; /** Model ids with a live pool. `modelId` is whichever of them is current. */ resident: any[]; /** Latest decode probe from an engine worker; see multistep.js. */ decode: any; }>; /** Unload every resident model. */ unloadAll(): Promise<{ status: string; modelId: any; progress: any; error: any; pool: { size: number; busy: number; queued: number; }; /** Model ids with a live pool. `modelId` is whichever of them is current. */ resident: any[]; /** Latest decode probe from an engine worker; see multistep.js. */ decode: any; }>; /** * One completion. * * Named `complete` rather than `chat` so `engine.chat.completions.create()` * — the WebLLM-shaped facade, Phase 2 — can take that name without a rename. * * @param {CompletionRequest} payload * @param {(delta: string) => void} [onChunk] called per streamed text delta * @returns {Promise} */ complete(payload: CompletionRequest, onChunk?: (delta: string) => void): Promise; /** * `complete()`, but the callback receives WebLLM's chunk verbatim. * * Exists so the `chat.completions.create()` facade can pass chunks straight * through instead of rebuilding an envelope — which is what dropped * `tool_calls`, flattened `logprobs` and restamped `created`. * * @param {CompletionRequest} payload * @param {(chunk: object) => void} [onRawChunk] * @returns {Promise}>} */ completeRaw(payload: CompletionRequest, onRawChunk?: (chunk: object) => void): Promise; }>; /** * One question, one answer, nothing kept. * * ```js * const answer = await engine.ask("Summarise this in one line:\n" + doc); * ``` * * @param {string | Array} input * @param {object} [opts] anything `complete()` takes, plus `onDelta` to stream * @returns {Promise} */ ask(input: string | Array, opts?: object): Promise; /** * A multi-turn conversation that keeps its own history. * * ```js * const chat = engine.conversation({ system: "You are terse." }); * await chat.say("hello"); * await chat.say("and again?"); // remembers * ``` * * @param {object} [opts] `system`, `keep`, plus `complete()` defaults */ conversation(opts?: object): { readonly messages: any[]; readonly length: number; say(content: string, onDelta?: (delta: string) => void): Promise<{ text: string; finishReason: "length" | "stop" | "abort"; }>; reset(): /*elided*/ any; restore(messages: any): /*elided*/ any; }; /** * Ghost text, with the debounce/supersede/drop-if-stale discipline built in * and the prompt left to you. * * ```js * const ghost = engine.ghostText({ prompt: (before) => `Continue:\n${before}` }); * editor.on("input", async () => { * const hint = await ghost.suggest(editor.textBefore()); * if (hint !== null) render(hint); // null means a newer keystroke won * }); * editor.on("blur", () => ghost.cancel()); * ``` * * @param {object} opts must include `prompt` */ ghostText(opts: object): { suggest(context: any): Promise; cancel(): number; }; /** * Embed text into vectors, through the same scheduler as everything else. * * ```js * const [vector] = await engine.embed("a sentence", { modelId: EMBED_MODEL }); * const vectors = await engine.embed(["one", "two"], { modelId: EMBED_MODEL }); * ``` * * **Needs an embedding model**, not a chat model — `snowflake-arctic-embed-*` * in WebLLM's prebuilt list, from 239 MB. They are separate models, so this * usually names `modelId` explicitly and holds it resident alongside a chat * model with `load(id, { keepResident: true })`. * * Returns bare vectors because that is what a caller does arithmetic on; the * OpenAI envelope is available as `embedRaw()` for anyone porting code that * expects `data[].embedding`. * * **A running embedding cannot be interrupted.** Cancellation and preemption * work by making a decode loop break out; one forward pass has no loop, so a * `cancel()` that lands after the job starts marks it cancelled but does not * stop it. Queued embeddings supersede and cancel normally. This is tolerable * because an embedding is milliseconds where a completion is seconds — but it * is a weaker guarantee than `complete()` gives, so it is stated rather than * discovered. * * @param {string | string[]} input * @param {{modelId?: string, task?: string, session?: string, * priority?: string, preemptible?: boolean, id?: string}} [opts] * @returns {Promise} one vector per input, in order */ embed(input: string | string[], opts?: { modelId?: string; task?: string; session?: string; priority?: string; preemptible?: boolean; id?: string; }): Promise; /** `embed()`, returning WebLLM's OpenAI-shaped envelope untouched. */ embedRaw(input: any, opts?: {}): Promise<{ data: any; usage: any; }>; /** * Independent prompts, fanned across the pool. This is the only way to beat * the ~10 tok/s single-stream ceiling, so anything embarrassingly parallel * (translating a page, labelling a list) should arrive here rather than as a * loop of `complete` calls. * * @param {CompletionRequest & {requests: Array>}} payload * @param {(item: BatchItem) => void} [onItem] called as each item lands * @returns {Promise>} */ batch(payload: CompletionRequest & { requests: Array>; }, onItem?: (item: BatchItem) => void): Promise>; /** * Cancels by job id or by session key. * @param {string} idOrSession * @returns {number} how many jobs it stopped */ cancel(idOrSession: string): number; /** * Applies a runtime knob to the running pool and persists it as the default. * * `decodeSteps` is the multi-step decode width (AI.md, "Multi-step decoding"). * It takes effect on the next burst — no reload — which is what makes sweeping * it to find this machine's tick boundary cheap. */ configure(patch: any): Promise<{ settings: { decodeSteps: number; engineCount: number; }; engines: number; }>; #private; } /** * The OpenAI generation fields WebLLM already speaks, plus the scheduling * fields that are what this engine adds over calling WebLLM directly. */ export type CompletionRequest = { messages: Array<{ role: string; content: string; }>; /** * load this model first if it is not the live one */ modelId?: string; /** * job id; also what `cancel(id)` takes */ id?: string; temperature?: number; max_tokens?: number; response_format?: object; extra_body?: object; /** * the unit that owns an engine; a whole batch shares one */ task?: string; /** * a later job with this key supersedes the earlier one */ session?: string; priority?: "interactive" | "normal" | "background"; /** * may be interrupted by an `interactive` job */ preemptible?: boolean; }; export type CompletionResult = { text: string; usage?: object; /** * WebLLM's own values */ finishReason?: "stop" | "length" | "abort"; /** * superseded or explicitly cancelled */ cancelled?: true; /** * an `interactive` job took the slot; `text` is partial */ preempted?: true; }; export type BatchItem = CompletionRequest & { index: number; engineIndex: number; startedAt: number; finishedAt: number; error?: string; }; import { ModelStore } from "./model-store.js";