chore: import upstream snapshot with attribution
Build / build (push) Has been cancelled
Tests / test (push) Has been cancelled
Build site and push to gh-pages / Build site (push) Has been cancelled
Linter / lint (push) Has been cancelled
Security / dependency-review (push) Has been cancelled
Security / npm-audit (push) Has been cancelled
Security / codeql (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:42:51 +08:00
commit f73e710e38
276 changed files with 36598 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
import * as tvmjs from "@mlc-ai/web-runtime";
import {
AppConfig,
ChatConfig,
ModelRecord,
prebuiltAppConfig,
getCacheBackend,
} from "./config";
import { cleanModelUrl } from "./support";
import { ModelNotFoundError, UnsupportedTokenizerFilesError } from "./error";
import { Tokenizer } from "@mlc-ai/web-tokenizers";
import { ModelIntegrity, verifyIntegrity } from "./integrity";
type CacheScope = "webllm/model" | "webllm/config" | "webllm/wasm";
type CacheOptions = Pick<
tvmjs.TensorCacheAccessOptions,
"cacheType" | "opfsAccessMode"
>;
export function getCacheOptions(appConfig: AppConfig): CacheOptions {
const options: CacheOptions = {
cacheType: getCacheBackend(appConfig),
};
if (appConfig.opfsAccessMode !== undefined) {
options.opfsAccessMode = appConfig.opfsAccessMode;
}
return options;
}
export function getTensorCacheAccessOptions(
scope: CacheScope,
appConfig: AppConfig,
): tvmjs.TensorCacheAccessOptions {
return {
cacheScope: scope,
...getCacheOptions(appConfig),
};
}
function createScopedArtifactCache(
scope: CacheScope,
appConfig: AppConfig,
): tvmjs.ArtifactCacheTemplate {
return tvmjs.createArtifactCache(scope, getCacheOptions(appConfig));
}
async function maybeVerifyTokenizerIntegrity(
data: ArrayBuffer,
filename: string,
url: string,
integrity?: ModelIntegrity,
): Promise<void> {
const hash = integrity?.tokenizer?.[filename];
if (hash) {
await verifyIntegrity(data, hash, url, integrity?.onFailure);
}
}
function findModelRecord(modelId: string, appConfig?: AppConfig): ModelRecord {
const matchedItem = appConfig?.model_list.find(
(item) => item.model_id == modelId,
);
if (matchedItem !== undefined) {
return matchedItem;
}
throw new ModelNotFoundError(modelId);
}
export async function hasModelInCache(
modelId: string,
appConfig?: AppConfig,
): Promise<boolean> {
if (appConfig === undefined) {
appConfig = prebuiltAppConfig;
}
const modelRecord = findModelRecord(modelId, appConfig);
const modelUrl = cleanModelUrl(modelRecord.model);
return tvmjs.hasTensorInCache(
modelUrl,
getTensorCacheAccessOptions("webllm/model", appConfig),
);
}
export async function deleteModelAllInfoInCache(
modelId: string,
appConfig?: AppConfig,
) {
// function to delete model all information in cache
if (appConfig === undefined) {
appConfig = prebuiltAppConfig;
}
// delete model and tokenizer in Cache
await deleteModelInCache(modelId, appConfig);
// delete wasm in cache
await deleteModelWasmInCache(modelId, appConfig);
// delete chat config
await deleteChatConfigInCache(modelId, appConfig);
}
export async function deleteModelInCache(
modelId: string,
appConfig?: AppConfig,
) {
// delete the model NDArray In Cache
if (appConfig === undefined) {
appConfig = prebuiltAppConfig;
}
const modelRecord = findModelRecord(modelId, appConfig);
const modelUrl = cleanModelUrl(modelRecord.model);
const modelCache = createScopedArtifactCache("webllm/model", appConfig);
await tvmjs.deleteTensorCache(
modelUrl,
getTensorCacheAccessOptions("webllm/model", appConfig),
);
await modelCache.deleteInCache(new URL("tokenizer.model", modelUrl).href);
await modelCache.deleteInCache(new URL("tokenizer.json", modelUrl).href);
}
export async function deleteChatConfigInCache(
modelId: string,
appConfig?: AppConfig,
) {
// delete the chat configuration in Cache
if (appConfig === undefined) {
appConfig = prebuiltAppConfig;
}
const modelRecord = findModelRecord(modelId, appConfig);
const configCache = createScopedArtifactCache("webllm/config", appConfig);
const modelUrl = cleanModelUrl(modelRecord.model);
const configUrl = new URL("mlc-chat-config.json", modelUrl).href;
await configCache.deleteInCache(configUrl);
}
export async function deleteModelWasmInCache(
modelId: string,
appConfig?: AppConfig,
) {
// delete the wasm in Cache
if (appConfig === undefined) {
appConfig = prebuiltAppConfig;
}
const modelRecord = findModelRecord(modelId, appConfig);
const wasmCache = createScopedArtifactCache("webllm/wasm", appConfig);
await wasmCache.deleteInCache(modelRecord.model_lib);
}
/**
*
* @param baseUrl The link to which we can find tokenizer files, usually is a `ModelRecord.model`.
* @param config A ChatConfig, usually loaded from `mlc-chat-config.json` in `baseUrl`.
* @param appConfig An AppConfig, usually `webllm.prebuiltAppConfig` if not defined by user.
* @param logger Logging function, console.log by default.
* @param integrity Optional integrity configuration for verifying tokenizer files.
* @returns
*/
export async function asyncLoadTokenizer(
baseUrl: string,
config: ChatConfig,
appConfig: AppConfig,
logger: (msg: string) => void = console.log,
integrity?: ModelIntegrity,
): Promise<Tokenizer> {
const modelCache = createScopedArtifactCache("webllm/model", appConfig);
if (config.tokenizer_files.includes("tokenizer.json")) {
const url = new URL("tokenizer.json", baseUrl).href;
const model = await modelCache.fetchWithCache(url, "arraybuffer");
await maybeVerifyTokenizerIntegrity(
model,
"tokenizer.json",
url,
integrity,
);
return Tokenizer.fromJSON(model);
} else if (config.tokenizer_files.includes("tokenizer.model")) {
logger(
"Using `tokenizer.model` since we cannot locate `tokenizer.json`.\n" +
"It is recommended to use `tokenizer.json` to ensure all token mappings are included, " +
"since currently, files like `added_tokens.json`, `tokenizer_config.json` are ignored.\n" +
"Consider converting `tokenizer.model` to `tokenizer.json` by compiling the model " +
"with MLC again, or see if MLC's huggingface provides this file.",
);
const url = new URL("tokenizer.model", baseUrl).href;
const model = await modelCache.fetchWithCache(url, "arraybuffer");
await maybeVerifyTokenizerIntegrity(
model,
"tokenizer.model",
url,
integrity,
);
return Tokenizer.fromSentencePiece(model);
}
throw new UnsupportedTokenizerFilesError(config.tokenizer_files);
}
+2603
View File
File diff suppressed because it is too large Load Diff
+568
View File
@@ -0,0 +1,568 @@
import {
ChatConfig,
ConvTemplateConfig,
MessagePlaceholders,
Role,
} from "./config";
import {
ChatCompletionContentPart,
ChatCompletionContentPartImage,
ChatCompletionMessageParam,
ChatCompletionRequest,
} from "./openai_api_protocols/index";
import {
ContentTypeError,
FunctionNotFoundError,
InvalidToolChoiceError,
MessageOrderError,
MultipleTextContentError,
SystemMessageOrderError,
TextCompletionConversationError,
TextCompletionConversationExpectsPrompt,
UnsupportedRoleError,
UnsupportedToolChoiceTypeError,
UnsupportedToolTypeError,
} from "./error";
type ImageURL = ChatCompletionContentPartImage.ImageURL;
/**
* Helper to keep track of history conversations.
*/
export class Conversation {
// NOTE: Update `compareConversationObject()` whenever a new state is introduced.
/** Each message is a tuple of (Role, role_name_str, message), where message can be either a
* string or an array of contentPart for possible image input.
*/
public messages: Array<
[Role, string, string | Array<ChatCompletionContentPart> | undefined]
> = [];
readonly config: ConvTemplateConfig;
/** Whether the Conversation object is for text completion with no conversation-style formatting */
public isTextCompletion: boolean;
/** Used when isTextCompletion is true */
public prompt: string | undefined;
public function_string = "";
public use_function_calling = false;
public override_system_message?: string = undefined;
/**
* Tracks whether the last message is an empty thinking block. Should only
* be true when we are in the middle of a generation. Will be set to
* false when the reply is finished with `finishReply()`.
*/
private isLastMessageEmptyThinkingReplyHeader = false;
// TODO(tvm-team) confirm and remove
// private contextWindowStart = 0;
constructor(config: ConvTemplateConfig, isTextCompletion = false) {
this.config = config;
this.isTextCompletion = isTextCompletion;
}
// TODO: Consider rewriting this method, a bit messy.
private getPromptArrayInternal(
addSystem: boolean,
startPos: number,
config?: ChatConfig,
): Array<string | Array<string | ImageURL>> {
if (this.config.seps.length == 0) {
throw Error("Need seps to work");
}
// Prepare system message
// Get overridden system message if exists, else use default one in config
let system_message = this.config.system_message;
if (this.override_system_message !== undefined) {
system_message = this.override_system_message;
}
const system_prompt = this.config.system_template.replace(
MessagePlaceholders.system,
system_message,
);
const ret: Array<string | Array<string | ImageURL>> =
addSystem && system_prompt !== "" ? [system_prompt] : [];
// Process each message in this.messages
for (let i = startPos; i < this.messages.length; ++i) {
const item = this.messages[i];
const role = item[0];
const role_str = item[1];
const messageContent = item[2];
// 1. Message from `appendReplyHeader()`, message is empty; not much processing is needed.
if (messageContent === undefined) {
if (i !== this.messages.length - 1) {
throw new Error(
"InternalError: Only expect message to be undefined for last " +
"message for a reply header.",
);
}
// Add ": " if there is no such field. If "", do not add sep
const empty_sep =
this.config.role_empty_sep || this.config.role_empty_sep == ""
? this.config.role_empty_sep
: ": ";
ret.push(role_str + empty_sep);
continue;
}
// 2. Message from `appendEmptyThinkingReplyHeader()`, message is an empty thinking block.
if (
this.isLastMessageEmptyThinkingReplyHeader &&
i === this.messages.length - 1
) {
// TODO(Charlie): content_sep or empty_sep? For Qwen3, both are "\n".
const content_sep =
this.config.role_content_sep || this.config.role_content_sep == ""
? this.config.role_content_sep
: ": ";
ret.push(role_str + content_sep + messageContent);
continue;
}
// 3. Each messageContent consists of one textPart, and >= 0 imageParts, regardless whether
// it is Array<ChatCompletionContentPart> or text message. So we extract out each.
let textContentPart = ""; // if no textPart, use an empty string
const imageContentParts: ImageURL[] = [];
if (Array.isArray(messageContent)) {
// 2.1 content is Array<ChatCompletionContentPart>
// Iterate through the contentParts, get the text and list of images. There should
// be only a single text. TODO: is it always the case the number of textContentPart <= 1?
let seenText = false;
for (let i = 0; i < messageContent.length; i++) {
const curContentPart = messageContent[i];
if (curContentPart.type === "text") {
if (seenText) {
throw new MultipleTextContentError();
}
textContentPart = curContentPart.text;
seenText = true;
} else {
imageContentParts.push(curContentPart.image_url);
}
}
} else {
// 2.2 content is just a string
textContentPart = messageContent;
}
// 3. Format textContentPart with role and sep to get message_str and role_prefix
let message_str;
let role_prefix;
if (this.config.role_templates !== undefined) {
message_str = this.config.role_templates[role]?.replace(
MessagePlaceholders[Role[role] as keyof typeof MessagePlaceholders],
textContentPart,
);
if (this.use_function_calling && this.function_string !== "") {
message_str = message_str?.replace(
MessagePlaceholders.function,
this.function_string,
);
}
message_str = message_str?.replace(MessagePlaceholders.function, "");
}
if (message_str == undefined) {
message_str = textContentPart;
}
if (
this.config.add_role_after_system_message === false &&
system_prompt != "" &&
i == 0
) {
role_prefix = "";
} else {
// Add ": " if there is no such field. If "", do not add sep
const content_sep =
this.config.role_content_sep || this.config.role_content_sep == ""
? this.config.role_content_sep
: ": ";
role_prefix = role_str + content_sep;
}
// 4. Combine everything together
if (imageContentParts.length === 0) {
// If no image, just a single string to represent this message
ret.push(
role_prefix +
message_str +
this.config.seps[i % this.config.seps.length],
);
} else {
// Per-model image layout
const curMessageList: Array<string | ImageURL> = [role_prefix];
const modelType = config?.model_type ?? "";
imageContentParts.forEach((curImage: ImageURL) => {
if (modelType === "phi3_v") {
curMessageList.push(curImage);
curMessageList.push("\n");
} else {
curMessageList.push(curImage);
}
});
curMessageList.push(
message_str + this.config.seps[i % this.config.seps.length],
);
ret.push(curMessageList);
}
}
return ret;
}
/**
* Get prompt arrays with the first one as system.
*
* It is returned as an array of `string | Array<string | ImageURL>`, where each element of
* the array represents the formatted message of a role/turn. If the message only contains text,
* it will be a string that concatenates the role string, message, and separators. If the
* message contains image(s), it will be an array of string and ImageURL in the order of which
* they will be prefilled into the model. e.g. it can be something like
* [
* "<|system|>\nSome system prompt\n",
* [
* "<|user|>\n",
* imageURL1,
* "\n",
* imageURL2,
* "\n",
* "Some user input<|end|>\n"
* ],
* ]
*
* @returns The prompt array.
*/
getPromptArray(
config?: ChatConfig,
): Array<string | Array<string | ImageURL>> {
if (this.isTextCompletion) {
throw new TextCompletionConversationError("getPromptArray");
}
return this.getPromptArrayInternal(true, 0, config);
}
/**
* Get the last round of prompt has not been fed as input.
*
* @note This function needs to be used with the assumption that
* the caller call appendMessage then appendReplyHeader.
*
* @returns The prompt array.
*/
getPromptArrayLastRound(config?: ChatConfig) {
if (this.isTextCompletion) {
throw new TextCompletionConversationError("getPromptArrayLastRound");
}
if (this.messages.length < 3) {
throw Error("needs to call getPromptArray for the first message");
}
return this.getPromptArrayInternal(false, this.messages.length - 2, config);
}
/**
* Return prompt in an array for non-conversation text completion.
*/
getPromptArrayTextCompletion(): Array<string> {
if (!this.isTextCompletion || this.prompt === undefined) {
throw new TextCompletionConversationExpectsPrompt();
}
return [this.prompt];
}
/**
* Resets all states for this.conversation.
*/
reset() {
// Note: Update this whenever we introduce a new state to conversation.
this.messages = [];
this.override_system_message = undefined;
this.function_string = "";
this.use_function_calling = false;
this.isTextCompletion = false;
this.prompt = undefined;
}
getStopStr(): string[] {
// TODO(Charlie): Is this needed?
// if (this.config.stop_str.length > 0) {
// return this.config.stop_str;
// }
// return [this.config.seps[this.config.seps.length - 1]];
return this.config.stop_str;
}
getStopTokens() {
return this.config.stop_token_ids;
}
appendMessage(
role: Role,
message: string | Array<ChatCompletionContentPart>,
role_name?: string,
) {
if (this.isTextCompletion) {
throw new TextCompletionConversationError("appendMessage");
}
if (
this.messages.length != 0 &&
this.messages[this.messages.length - 1][2] == undefined
) {
throw Error("Have unfinished reply");
}
if (!(role in this.config.roles)) {
throw Error("Role is not supported: " + role);
}
const role_name_str = role_name ? role_name : this.config.roles[role];
this.messages.push([role, role_name_str, message]);
}
appendReplyHeader(role: Role) {
if (this.isTextCompletion) {
throw new TextCompletionConversationError("appendReplyHeader");
}
if (!(role in this.config.roles)) {
throw Error("Role is not supported: " + role);
}
this.messages.push([role, this.config.roles[role], undefined]);
}
appendEmptyThinkingReplyHeader(role: Role, emptyThinkingBlockStr: string) {
if (this.isTextCompletion) {
throw new TextCompletionConversationError(
"appendEmptyThinkingReplyHeader",
);
}
this.isLastMessageEmptyThinkingReplyHeader = true;
this.messages.push([role, this.config.roles[role], emptyThinkingBlockStr]);
}
finishReply(message: string) {
if (this.isTextCompletion) {
throw new TextCompletionConversationError("finishReply");
}
if (this.messages.length == 0) {
throw Error("Message error should not be 0");
}
if (
this.messages[this.messages.length - 1][2] !== undefined &&
// If the last message has an empty thinknig block, last message is expected
// to be non-empty.
this.isLastMessageEmptyThinkingReplyHeader === false
) {
throw Error("Already assigned");
}
this.messages[this.messages.length - 1][2] = message;
this.isLastMessageEmptyThinkingReplyHeader = false;
}
}
export function getConversation(
conv_template: ConvTemplateConfig,
conv_config?: Partial<ConvTemplateConfig>,
isTextCompletion = false,
): Conversation {
return new Conversation(
{ ...conv_template, ...conv_config },
isTextCompletion,
);
}
/**
* Compare the states of two conversation instances. Equality is defined as their getPromptArray()
* should return the exact same things, which is determined by fields: messages, function_string,
* use_function_calling, and override_system_message.
*
* @returns True if `convA` equals to `convB`
* @note We assume convA and convB has the same `this.config`.
*/
export function compareConversationObject(
convA: Conversation,
convB: Conversation,
): boolean {
// NOTE: Update this function whenever a new state is introduced to `Conversation`.
// Check the easy ones first
if (
convA.function_string !== convB.function_string ||
convA.use_function_calling !== convB.use_function_calling ||
convA.override_system_message !== convB.override_system_message ||
convA.messages.length !== convB.messages.length ||
convA.isTextCompletion !== convB.isTextCompletion
) {
return false;
}
// Then check message
if (convA.messages.length === 0 && convB.messages.length === 0) {
// both are empty
return true;
}
if (convA.messages.length !== convB.messages.length) {
// different number of messages
return false;
}
const msgLen = convA.messages.length;
const msgEntryLen = convA.messages[0].length; // always 3 for now
for (let i = 0; i < msgLen; i++) {
for (let j = 0; j < msgEntryLen; j++) {
const entryA = convA.messages[i][j];
const entryB = convB.messages[i][j];
if (typeof entryA === "string" && typeof entryB === "string") {
// Case 1: both are strings
if (convA.messages[i][j] !== convB.messages[i][j]) {
return false;
}
} else if (entryA === undefined && entryB === undefined) {
// Case 2: both undefined
continue;
} else if (Array.isArray(entryA) && Array.isArray(entryB)) {
// Case 3: both are ChatCompletionContentPart[]
if (entryA.length !== entryB.length) {
return false;
}
const numContentParts = entryA.length;
for (let k = 0; k < numContentParts; k++) {
const entryA_k = entryA[k];
const entryB_k = entryB[k];
if (entryA_k.type === "text" && entryB_k.type === "text") {
// Case 3.1: both are text
if (entryA_k.text !== entryB_k.text) {
return false;
}
} else if (
entryA_k.type === "image_url" &&
entryB_k.type === "image_url"
) {
// Case 3.2: both are image_url
if (
entryA_k.image_url.url !== entryB_k.image_url.url ||
entryA_k.image_url.detail !== entryB_k.image_url.detail
) {
return false;
}
} else {
// Case 3.3: of different type
return false;
}
}
} else {
// Case 4: two entries are of different types
return false;
}
}
}
return true;
}
/**
* Get a new Conversation object based on the chat completion request.
*
* @param request The incoming ChatCompletionRequest
* @param includeLastMsg Include last message, by default is false. Set to true for testing only.
* @note By default, `request.messages[-1]` is not included as it would be treated as a normal
* input to `prefill()`.
*/
export function getConversationFromChatCompletionRequest(
request: ChatCompletionRequest,
config: ChatConfig,
includeLastMsg = false,
): Conversation {
// 0. Instantiate a new Conversation object
const conversation = getConversation(
config.conv_template,
config.conv_config,
);
// 1. Populate function-calling-related fields
// TODO: either remove these or support gorilla-like function calling models.
// These commented code was used to support gorilla, but we could not use grammar to
// guarantee its output, nor make it conform to OpenAI's function calling output. Kept for now.
// const functionCallUsage = this.getFunctionCallUsage(request);
// conversation.function_string = functionCallUsage;
// conversation.use_function_calling = functionCallUsage !== "";
// 2. Populate conversation.messages
const input = request.messages;
const lastId = input.length - 1;
if (input[lastId].role !== "user" && input[lastId].role !== "tool") {
throw new MessageOrderError(
"The last message should be from the `user` or `tool`.",
);
}
const iterEnd = includeLastMsg ? input.length : input.length - 1;
for (let i = 0; i < iterEnd; i++) {
const message: ChatCompletionMessageParam = input[i];
if (message.role === "system") {
if (i !== 0) {
throw new SystemMessageOrderError();
}
conversation.override_system_message = message.content;
} else if (message.role === "user") {
conversation.appendMessage(Role.user, message.content, message.name);
} else if (message.role === "assistant") {
if (typeof message.content !== "string") {
throw new ContentTypeError(message.role + "'s message");
}
conversation.appendMessage(Role.assistant, message.content, message.name);
} else if (message.role === "tool") {
conversation.appendMessage(Role.tool, message.content);
} else {
// Use `["role"]` instead of `.role` to suppress "Property does not exist on type 'never'"
throw new UnsupportedRoleError(message["role"]);
}
}
return conversation;
}
/**
* Returns the function string based on the request.tools and request.tool_choice, raises erros if
* encounter invalid request.
*
* @param request The chatCompletionRequest we are about to prefill for.
* @returns The string used to set Conversation.function_string
*/
export function getFunctionCallUsage(request: ChatCompletionRequest): string {
if (
request.tools == undefined ||
(typeof request.tool_choice == "string" && request.tool_choice == "none")
) {
return "";
}
if (
typeof request.tool_choice == "string" &&
request.tool_choice !== "auto"
) {
throw new InvalidToolChoiceError(request.tool_choice);
}
if (
typeof request.tool_choice !== "string" &&
request.tool_choice?.type !== "function"
) {
throw new UnsupportedToolChoiceTypeError();
}
const singleFunctionToCall =
typeof request.tool_choice !== "string" &&
request.tool_choice?.function?.name;
if (singleFunctionToCall) {
for (const f of request.tools) {
if (singleFunctionToCall == f.function.name) {
return JSON.stringify([f.function]);
}
}
throw new FunctionNotFoundError(singleFunctionToCall);
}
const function_list = [];
for (const f of request.tools) {
if (f.type !== "function") {
throw new UnsupportedToolTypeError();
}
function_list.push(f.function);
}
return JSON.stringify(function_list);
}
+294
View File
@@ -0,0 +1,294 @@
import * as tvmjs from "@mlc-ai/web-runtime";
import log from "loglevel";
import { Tokenizer } from "@mlc-ai/web-tokenizers";
import { ChatConfig } from "./config";
import {
EmbeddingChunkingUnsupportedError,
EmbeddingExceedContextWindowSizeError,
EmbeddingInputEmptyError,
EmbeddingSlidingWindowError,
MinValueError,
} from "./error";
export class EmbeddingPipeline {
private config: ChatConfig;
private tokenizer: Tokenizer;
// TVM functions
private tvm: tvmjs.Instance;
private device: tvmjs.DLDevice;
private vm: tvmjs.VirtualMachine;
private prefill: tvmjs.PackedFunc;
private params: tvmjs.TVMObject;
// metadata
private contextWindowSize = -1;
private prefillChunkSize = -1;
private maxBatchSize = -1;
// performance
private curRoundEmbedTotalTokens = 0; // excludes padded tokens for batching
private curRoundEmbedTotalTime = 0;
constructor(tvm: tvmjs.Instance, tokenizer: Tokenizer, config: ChatConfig) {
// 0. Setting attributes
this.tvm = tvm;
this.tokenizer = tokenizer;
this.config = config;
this.device = this.tvm.webgpu();
// 1. Create VM and get the core functions
tvm.beginScope();
this.vm = this.tvm.detachFromCurrentScope(
this.tvm.createVirtualMachine(this.device),
);
this.prefill = this.tvm.detachFromCurrentScope(
this.vm.getFunction("prefill"),
);
// 2. Get json stored in the vm's metadata function
const fgetMetadata = this.vm.getFunction("_metadata");
const ret_value = fgetMetadata();
const metadataStr = ret_value.toString();
const metadata = JSON.parse(metadataStr);
// 3. Load parameters by name
const paramNames: string[] = [];
metadata.params.forEach((param: any) => {
paramNames.push(param.name);
});
this.params = this.tvm.detachFromCurrentScope(
this.tvm.getParamsFromCacheByName(paramNames),
);
// 4. Read in compilation configurations from metadata
// We use context window size max batch size to check validity of the model
// We assume prefillChunkSize is the same as contextWindowSize for embedding model for now
this.maxBatchSize = metadata.max_batch_size;
this.contextWindowSize = this.config.context_window_size;
this.prefillChunkSize = metadata.prefill_chunk_size;
log.info("Using maxBatchSize: ", this.maxBatchSize);
log.info("Using contextWindowSize: ", this.contextWindowSize);
log.info("Using prefillChunkSize: ", this.prefillChunkSize);
if (this.config.sliding_window_size !== -1) {
throw new EmbeddingSlidingWindowError(this.config.sliding_window_size);
}
if (this.maxBatchSize <= 0) {
throw new MinValueError("maxBatchSize", 0);
}
if (this.contextWindowSize <= 0) {
throw new MinValueError("contextWindowSize", 0);
}
if (this.prefillChunkSize <= 0) {
throw new MinValueError("prefillChunkSize", 0);
}
if (this.prefillChunkSize !== this.contextWindowSize) {
throw new EmbeddingChunkingUnsupportedError(
this.contextWindowSize,
this.prefillChunkSize,
);
}
tvm.endScope();
}
async embedStep(
input: string | Array<string> | Array<number> | Array<Array<number>>,
): Promise<Array<Array<number>>> {
// 0. Reset performance metrics
this.curRoundEmbedTotalTokens = 0;
this.curRoundEmbedTotalTime = 0;
let totalNumTokens = 0;
const embedStart = performance.now();
let tokenizedInputs: Array<Array<number>> = [];
const tempInputs: Array<number> = [];
// 1. Convert all possible input types to Array<Array<number>>, tokenize if not already
// Cannot use input.every to match type, which leads to TS compilation error
// https://github.com/microsoft/TypeScript/issues/33591
if (input.length === 0) {
throw new EmbeddingInputEmptyError();
}
if (typeof input === "string") {
// string
tokenizedInputs = [Array.from(this.tokenizer.encode(input))];
} else {
for (let i = 0; i < input.length; i++) {
const curInput = input[i];
if (Array.isArray(curInput)) {
// Array<Array<number>>
tokenizedInputs.push(curInput);
} else if (typeof curInput === "string") {
// Array<string>
tokenizedInputs.push(Array.from(this.tokenizer.encode(curInput)));
} else {
// Array<number>
tempInputs.push(curInput);
}
}
}
if (tempInputs.length > 0) {
tokenizedInputs.push(tempInputs);
}
// 2. Check each input is not larger than the context window size
// TODO: tokenizer.encode seems to implicitly truncates to contextWindowSize, confirm behavior
// and decide whether to warn user
for (let i = 0; i < tokenizedInputs.length; i++) {
const curInputSize = tokenizedInputs[i].length;
totalNumTokens += curInputSize;
if (curInputSize > this.contextWindowSize) {
throw new EmbeddingExceedContextWindowSizeError(
this.contextWindowSize,
curInputSize,
);
}
}
if (tokenizedInputs.length === 0) {
throw new Error("InternalError: batch size is zero.");
}
// 3. Forward each batch
const batchSize = tokenizedInputs.length;
const result: Array<Array<number>> = [];
for (let begin = 0; begin < batchSize; begin += this.maxBatchSize) {
this.tvm.beginScope();
// 3.1 Get current batch
const end = Math.min(batchSize, begin + this.maxBatchSize);
const curBatch: Array<Array<number>> = tokenizedInputs.slice(begin, end);
const curBatchSize = curBatch.length;
// 3.2 Max input size of current batch
let maxInputSize = 0;
for (let i = 0; i < curBatchSize; i++) {
const curInputSize = curBatch[i].length;
if (curInputSize > maxInputSize) {
maxInputSize = curInputSize;
}
}
// 3.3 Create inputs and attention mask
// Padded with zeros and flattened, of size curBatchSize * maxInputSize
const curBatchPaddedFlatten: Array<number> = [];
// 1 for non-pad, 0 otherwise, also of size curBatchSize * maxInputSize
const curAttnMask: Array<number> = [];
const flattenedInputSize = curBatchSize * maxInputSize;
for (let i = 0; i < curBatchSize; i++) {
const padding = Array(maxInputSize - curBatch[i].length).fill(0);
const ones = Array(curBatch[i].length).fill(1);
curBatchPaddedFlatten.push(...curBatch[i]);
curAttnMask.push(...ones);
curBatchPaddedFlatten.push(...padding);
curAttnMask.push(...padding);
}
if (
curBatchPaddedFlatten.length !== flattenedInputSize ||
curAttnMask.length !== flattenedInputSize
) {
throw new Error(
`InternalError: Expect input array to be ${flattenedInputSize}, ` +
`but got ${curBatchPaddedFlatten.length}`,
);
}
// 3.4 Convert inputs and attention mask to tvm ndarray on GPU, of shape (curBatchSize, maxInputSize)
let inputNDArray = this.tvm.empty(
[flattenedInputSize],
"int32",
this.device,
);
inputNDArray.copyFrom(curBatchPaddedFlatten);
inputNDArray = inputNDArray.view([curBatchSize, maxInputSize]);
let maskNDArray = this.tvm.empty(
[flattenedInputSize],
"int32",
this.device,
);
maskNDArray.copyFrom(curAttnMask);
maskNDArray = maskNDArray.view([curBatchSize, maxInputSize]);
// 3.5 Actual forwarding on GPU, logits of shape (curBatchSize, maxInputSize, hidden_size)
const logitsCurBatchOnGPU: tvmjs.Tensor = this.prefill(
inputNDArray,
maskNDArray,
this.params,
);
await this.device.sync();
// 3.6 Copy logits to CPU, flatten to curBatchSize * maxInputSize * hidden_size
const hidden_size = logitsCurBatchOnGPU.shape[2];
let logitsCurBatchOnCPU: tvmjs.Tensor = this.tvm.empty(
logitsCurBatchOnGPU.shape,
logitsCurBatchOnGPU.dtype,
this.tvm.cpu(),
);
logitsCurBatchOnCPU.copyFrom(logitsCurBatchOnGPU);
logitsCurBatchOnCPU = logitsCurBatchOnCPU.view([
curBatchSize * maxInputSize * hidden_size,
]);
await this.device.sync();
const logitsCurBatchOnCPUArray: Float32Array = <Float32Array>(
logitsCurBatchOnCPU.toArray()
);
// 3.7 Update final result. For each sentence, get [0,:], i.e. only the first token's output
// That is, we are doing result.push(logits[:,0,:]) here.
// TODO: check if all models only use [0,:]. If it is snowflake-specific, need to specify
// this in mlc-chat-config.json
for (let i = 0; i < curBatchSize; i++) {
const b = i * maxInputSize * hidden_size;
const e = b + hidden_size;
result.push(Array.from(logitsCurBatchOnCPUArray.slice(b, e)));
}
this.tvm.endScope();
}
if (result.length !== batchSize) {
throw new Error(`
InternalError: expect result.length to be ${batchSize}, but got ${result.length}`);
}
const embedEnd = performance.now();
this.curRoundEmbedTotalTokens = totalNumTokens;
this.curRoundEmbedTotalTime = (embedEnd - embedStart) / 1e3;
return result;
}
dispose() {
this.params.dispose();
this.prefill.dispose();
this.vm.dispose();
this.tvm.dispose();
this.tokenizer.dispose();
}
/**
* Synchronize the device.
*/
async sync(): Promise<void> {
// Is it equivalent to this.tvm.sync()?
await this.device.sync();
}
async asyncLoadWebGPUPipelines() {
await this.tvm.asyncLoadWebGPUPipelines(this.vm.getInternalModule());
}
// Performance APIs below
/**
* Get the time it took the last `embedStep()` in seconds.
*/
getCurRoundEmbedTotalTime(): number {
return this.curRoundEmbedTotalTime;
}
/**
* Get the number of tokens embedded in the last `embedStep()`. This excludes the padded tokens.
*/
getCurRoundEmbedTotalTokens(): number {
return this.curRoundEmbedTotalTokens;
}
/**
* @returns Prefill tokens per second, starting from the last prefill performed.
*/
getCurRoundEmbedTokensPerSec(): number {
return this.curRoundEmbedTotalTokens / this.curRoundEmbedTotalTime;
}
}
+1432
View File
File diff suppressed because it is too large Load Diff
+629
View File
@@ -0,0 +1,629 @@
export class ModelNotFoundError extends Error {
constructor(modelId: string) {
super(
`Cannot find model record in appConfig for ${modelId}. Please check if the model ID is correct and included in the model_list configuration.`,
);
this.name = "ModelNotFoundError";
}
}
export class ConfigValueError extends Error {
constructor(message: string) {
super(message);
this.name = "ConfigValueError";
}
}
export class MinValueError extends ConfigValueError {
constructor(paramName: string, minValue: number) {
super(`Make sure \`${paramName}\` > ${minValue}.`);
this.name = "MinValueError";
}
}
export class RangeError extends ConfigValueError {
constructor(
paramName: string,
minValue: number,
maxValue: number,
additionalMessage?: string,
) {
super(
`Make sure ${minValue} < ${paramName} <= ${maxValue}.${additionalMessage ? " " + additionalMessage : ""}`,
);
this.name = "RangeError";
}
}
export class NonNegativeError extends ConfigValueError {
constructor(paramName: string) {
super(`Make sure ${paramName} >= 0.`);
this.name = "NonNegativeError";
}
}
export class InvalidNumberStringError extends ConfigValueError {
constructor(paramName: string, actualValue?: string) {
super(
`Make sure ${paramName} to be number represented in string.${actualValue ? " Got " + actualValue : ""}`,
);
this.name = "InvalidNumberStringError";
}
}
export class DependencyError extends ConfigValueError {
constructor(
dependentParam: string,
requiredParam: string,
requiredValue: any,
) {
super(
`${dependentParam} requires ${requiredParam} to be ${requiredValue}.`,
);
this.name = "DependencyError";
}
}
export class WebGPUNotAvailableError extends Error {
constructor() {
super(
"WebGPU is not supported in your current environment, but it is necessary to run the WebLLM engine. " +
"Please make sure that your browser supports WebGPU and that it is enabled in your browser settings. " +
"You can also consult your browser's compatibility chart to see if it supports WebGPU. " +
"For more information about WebGPU support in your browser, visit https://webgpureport.org/",
);
this.name = "WebGPUNotAvailableError";
}
}
export class WebGPUNotFoundError extends Error {
constructor() {
super("Cannot find WebGPU in the environment");
this.name = "WebGPUNotFoundError";
}
}
export class ModelNotLoadedError extends Error {
constructor(requestName: string) {
super(
`Model not loaded before trying to complete ${requestName}. Please ensure you have called ` +
`MLCEngine.reload(model) to load the model before initiating APIs, ` +
`or initialize your engine using CreateMLCEngine() with a valid model configuration.`,
);
this.name = "ModelNotLoadedError";
}
}
export class WorkerEngineModelNotLoadedError extends Error {
constructor(engineName: string) {
super(
`${engineName} is not loaded with a model. Did you call \`engine.reload()\`?`,
);
this.name = "WorkerEngineModelNotLoadedError";
}
}
export class MessageOrderError extends Error {
constructor(message: string) {
super(message);
this.name = "MessageOrderError";
}
}
export class SystemMessageOrderError extends Error {
constructor() {
super("System prompt should always be the first message in `messages`.");
this.name = "SystemMessageOrderError";
}
}
export class ContentTypeError extends Error {
constructor(name: string) {
super(`${name} should have string content.`);
this.name = "ContentTypeError";
}
}
export class UnsupportedRoleError extends Error {
constructor(role: string) {
super(`Unsupported role of message: ${role}`);
this.name = "UnsupportedRoleError";
}
}
export class UserMessageContentErrorForNonVLM extends Error {
constructor(modelId: string, modelType: string, content: any) {
super(
`The model loaded is not of type ModelType.VLM (vision-language model). ` +
`Therefore, user message only supports string content, but received: ${content}\n` +
`Loaded modelId: ${modelId}, modelType: ${modelType}`,
);
this.name = "UserMessageContentErrorForNonVLM";
}
}
export class PrefillChunkSizeSmallerThanImageError extends Error {
constructor(prefillChunkSize: number, imageEmbedSize: number) {
super(
`prefillChunkSize needs to be greater than imageEmbedSize because a single image's ` +
`prefill cannot be chunked. Got prefillChunkSize: ` +
`${prefillChunkSize}, imageEmbedSize: ${imageEmbedSize}`,
);
this.name = "PrefillChunkSizeSmallerThanImageError";
}
}
export class CannotFindImageEmbedError extends Error {
constructor() {
super(
`Received image input but model does not have kernel image_embed. ` +
`Make sure to only pass in image to a vision model.`,
);
this.name = "CannotFindImageEmbedError";
}
}
export class UnsupportedDetailError extends Error {
constructor(detail: string) {
super(
`Currently do not support field image_url.detail, but received: ${detail}`,
);
this.name = "UnsupportedDetailError";
}
}
export class UnsupportedImageURLError extends Error {
constructor(url: string) {
super(
`image_url.url should start with "data:image" for base64, or with "http", but got: ${url}`,
);
this.name = "UnsupportedImageURLError";
}
}
export class MultipleTextContentError extends Error {
constructor() {
super(
`Each message can have at most one text contentPart, but received more than 1.`,
);
this.name = "MultipleTextContentError";
}
}
export class ToolCallOutputParseError extends Error {
constructor(outputMessage: string, error: Error) {
super(
`Internal error: error encountered when parsing outputMessage for function ` +
`calling. Got outputMessage: ${outputMessage}\nGot error: ${error}`,
);
this.name = "ToolCallOutputParseError";
}
}
export class ToolCallOutputInvalidTypeError extends Error {
constructor(expectedType: string) {
super(
`Internal error: expect output of function calling to be an ${expectedType}`,
);
this.name = "ToolCallOutputInvalidTypeError";
}
}
export class ToolCallOutputMissingFieldsError extends Error {
constructor(missingFields: string[], object: any) {
super(
`Expect generated tool call to have fields ${missingFields.map((field) => `"\`${field}\`"`).join(", ")}, but got object: ${JSON.stringify(object)}`,
);
this.name = "JSONFieldError";
}
}
export class ConfigurationNotInitializedError extends Error {
constructor() {
super(
"Configuration not initialized. Ensure you have called `reload()` function first.",
);
this.name = "ConfigurationNotInitializedError";
}
}
export class MissingModelWasmError extends Error {
constructor(modelId: string) {
super(
`Missing \`model_lib\` for the model with ID "${modelId}". Please ensure that \`model_lib\` is provided in \`model_list\` for each model. This URL is essential for downloading the WASM library necessary to run the model.`,
);
this.name = "MissingModelError";
}
}
export class FeatureSupportError extends Error {
constructor(feature: string) {
super(
`This model requires feature ${feature}, which is not yet supported by this browser.`,
);
this.name = "FeatureSupportError";
}
}
export class UnsupportedFieldsError extends Error {
constructor(unsupportedFields: string[], targetClass: string) {
super(
`The following fields in ${targetClass} are not yet supported: \n` +
unsupportedFields.join(", "),
);
this.name = "UnsupportedFieldsError";
}
}
export class ShaderF16SupportError extends FeatureSupportError {
constructor() {
super(
"This model requires WebGPU extension shader-f16, which is not enabled in this browser. " +
'You can try to launch Chrome Canary in command line with flag "--enable-dawn-features=allow_unsafe_apis".',
);
this.name = "ShaderF16SupportError";
}
}
export class DeviceLostError extends Error {
constructor() {
super(
"The WebGPU device was lost while loading the model. This issue often occurs due to running out of memory (OOM). To resolve this, try reloading with a model that has fewer parameters or uses a smaller context length.",
);
this.name = "DeviceLostError";
}
}
export class InvalidToolChoiceError extends Error {
constructor(toolChoice: string) {
super(
`Invalid tool choice value: '${toolChoice}'. Please check your input and try again.`,
);
this.name = "InvalidToolChoiceError";
}
}
export class UnsupportedToolChoiceTypeError extends Error {
constructor() {
super(
"Unsupported tool choice type. Only tool choices of type 'function' are supported.",
);
this.name = "UnsupportedToolChoiceTypeError";
}
}
export class FunctionNotFoundError extends Error {
constructor(functionName: string) {
super(
`The tool choice function ${functionName} is not found in the tools list`,
);
this.name = "FunctionNotFoundError";
}
}
export class UnsupportedToolTypeError extends Error {
constructor() {
super("Only 'function' tool type is supported");
this.name = "UnsupportedToolTypeError";
}
}
export class EngineNotLoadedError extends Error {
constructor() {
super(
"Engine not yet loaded with model. Ensure you initialize the chat module by calling `engine.reload()` first.",
);
this.name = "EngineNotLoadedError";
}
}
export class UnsupportedTokenizerFilesError extends Error {
constructor(files: string[]) {
super(`Cannot handle tokenizer files ${files}`);
this.name = "UnsupportedTokenizerFilesError";
}
}
export class WindowSizeConfigurationError extends Error {
constructor(contextWindowSize: number, slidingWindowSize: number) {
super(
`Only one of context_window_size and sliding_window_size can be positive. Got: ` +
`context_window_size: ${contextWindowSize}, sliding_window_size: ${slidingWindowSize}\n` +
`Consider modifying ModelRecord.overrides to set one of them to -1.`,
);
this.name = "WindowSizeConfigurationError";
}
}
export class AttentionSinkSizeError extends Error {
constructor() {
super(
"Need to specify non-negative attention_sink_size if using sliding window. " +
"Consider modifying ModelRecord.overrides. " +
"Use `attention_sink_size=0` for default sliding window.",
);
this.name = "AttentionSinkSizeError";
}
}
export class WindowSizeSpecificationError extends Error {
constructor() {
super(
"Need to specify either sliding_window_size or max_window_size.\n" +
"Consider modifying ModelRecord.overrides to set one of them to positive.",
);
this.name = "WindowSizeSpecificationError";
}
}
export class ContextWindowSizeExceededError extends Error {
constructor(numPromptTokens: number, contextWindowSize: number) {
super(
`Prompt tokens exceed context window size: number of prompt tokens: ${numPromptTokens}; ` +
`context window size: ${contextWindowSize}\nConsider shortening the prompt, or increase ` +
"`context_window_size`, or using sliding window via `sliding_window_size`.",
);
this.name = "ContextWindowSizeExceededError";
}
}
export class NonWorkerEnvironmentError extends Error {
constructor(className: string) {
super(`${className} must be created in the service worker script.`);
this.name = "NonWorkerEnvironmentError";
}
}
export class NoServiceWorkerAPIError extends Error {
constructor() {
super(
"Service worker API is not available in your browser. Please ensure that your browser supports service workers and that you are using a secure context (HTTPS). " +
"Check the browser compatibility and ensure that service workers are not disabled in your browser settings.",
);
this.name = "NoServiceWorkerAPIError";
}
}
export class ServiceWorkerInitializationError extends Error {
constructor() {
super(
"Service worker failed to initialize. This could be due to a failure in the service worker registration process or because the service worker is not active. " +
"Please refresh the page to retry initializing the service worker.",
);
this.name = "ServiceWorkerInitializationError";
}
}
export class StreamingCountError extends Error {
constructor() {
super("When streaming, `n` cannot be > 1.");
this.name = "StreamingCountError";
}
}
export class SeedTypeError extends Error {
constructor(seed: any) {
super("`seed` should be an integer, but got " + seed);
this.name = "SeedTypeError";
}
}
export class InvalidResponseFormatError extends Error {
constructor() {
super("JSON schema is only supported with `json_object` response format.");
this.name = "InvalidResponseFormatError";
}
}
export class InvalidResponseFormatGrammarError extends Error {
constructor() {
super(
"When ResponseFormat.type is `grammar`, ResponseFormat.grammar needs to be specified.\n" +
"When ResponseFormat.grammar is specified, ResponseFormat.type needs to be grammar.",
);
this.name = "InvalidResponseFormatGrammarError";
}
}
export class InvalidResponseFormatStructuralTagError extends Error {
constructor() {
super(
"When ResponseFormat.type is `structural_tag`, ResponseFormat.structural_tag needs to be specified.\n" +
"When ResponseFormat.structural_tag is specified, ResponseFormat.type needs to be structural_tag.",
);
this.name = "InvalidResponseFormatStructuralTagError";
}
}
export class CustomResponseFormatError extends Error {
constructor(currentFormat: any) {
super(
"When using Hermes-2-Pro function calling via ChatCompletionRequest.tools, " +
"cannot specify customized response_format. We will set it for you internally. Currently " +
"set to: " +
JSON.stringify(currentFormat),
);
this.name = "CustomResponseFormatError";
}
}
export class UnsupportedModelIdError extends Error {
constructor(currentModelId: string, supportedModelIds: string[]) {
super(
`${currentModelId} is not supported for ChatCompletionRequest.tools. Currently, models ` +
`that support function calling are: ${supportedModelIds.join(", ")}`,
);
this.name = "UnsupportedModelIdError";
}
}
export class CustomSystemPromptError extends Error {
constructor() {
super(
"When using Hermes-2-Pro function calling via ChatCompletionRequest.tools, cannot specify customized system prompt.",
);
this.name = "CustomSystemPromptError";
}
}
export class InvalidStreamOptionsError extends Error {
constructor() {
super("Only specify stream_options when stream=True.");
this.name = "InvalidStreamOptionsError";
}
}
export class UnknownMessageKindError extends Error {
constructor(msgKind: string, msgContent: any) {
super(`Unknown message kind, msg: [${msgKind}] ${msgContent}`);
this.name = "UnknownMessageKindError";
}
}
export class TextCompletionExpectsKVEmptyError extends Error {
constructor() {
super("Non-chat text completion API expects KVCache to be empty.");
this.name = "TextCompletionExpectsKVEmptyError";
}
}
export class TextCompletionConversationExpectsPrompt extends Error {
constructor() {
super(
"Non-chat text completion API expects isTextCompletion is true, and prompt is defined.",
);
this.name = "TextCompletionConversationExpectsPrompt";
}
}
export class TextCompletionConversationError extends Error {
constructor(funcName: string) {
super(`Non-chat text completion API cannot call ${funcName}.`);
this.name = "TextCompletionConversationError";
}
}
export class EmbeddingUnsupportedEncodingFormatError extends Error {
constructor() {
super("Embedding in base64 format is currently not supported.");
this.name = "EmbeddingUnsupportedEncodingFormatError";
}
}
export class EmbeddingUnsupportedModelError extends Error {
constructor(currentModel: string) {
super(
`Trying to run embeddings.create() with ${currentModel}, which does not have ` +
`ModelRecord.model_type === ModelType.embedding in the model record. ` +
`Either make sure an embedding model is loaded, or specify the model type in ModelRecord.`,
);
this.name = "EmbeddingUnsupportedModelError";
}
}
export class EmbeddingSlidingWindowError extends Error {
constructor(sliding_window_size: number) {
super(
`Embedding should not use sliding window. However, ` +
`sliding_window_size=${sliding_window_size} is specified in the chat config.`,
);
this.name = "EmbeddingSlidingWindowError";
}
}
export class EmbeddingChunkingUnsupportedError extends Error {
constructor(contextWindowSize: number, prefillChunkSize: number) {
super(
`Embedding currently does not support chunking. Make sure ` +
`contextWindowSize === prefillChunkSize. Got contextWindowSize=${contextWindowSize}, ` +
`prefillChunkSize=${prefillChunkSize} instead.`,
);
this.name = "EmbeddingChunkingUnsupportedError";
}
}
export class EmbeddingExceedContextWindowSizeError extends Error {
constructor(contextWindowSize: number, receivedSize: number) {
super(
`The embedding model you are using only supports up to ${contextWindowSize} context size.` +
`However, an input in the batch has size ${receivedSize}.`,
);
this.name = "EmbeddingExceedContextWindowSizeError";
}
}
export class EmbeddingInputEmptyError extends Error {
constructor() {
super("Embedding input cannot be empty string or empty token array.");
this.name = "EmbeddingInputEmptyError";
}
}
export class ReloadArgumentSizeUnmatchedError extends Error {
constructor(numModelId: number, numChatOpts: number) {
super(
`Expect chatOpts, if specified, to match the size of modelId. However, got ` +
`${numModelId} modelId, but ${numChatOpts} chatOpts.`,
);
this.name = "ReloadArgumentSizeUnmatchedError";
}
}
export class UnclearModelToUseError extends Error {
constructor(loadedModels: string[], requestName: string) {
super(
`Multiple models are loaded in engine. Please specify the model in ${requestName}.\n` +
`Currently loaded models are:\n${loadedModels}`,
);
this.name = "UnclearModelToUseError";
}
}
export class SpecifiedModelNotFoundError extends Error {
constructor(
loadedModels: string[],
requestedModelId: string,
requestName: string,
) {
super(
`Specified model ${requestedModelId} for ${requestName} is not found in loaded models. ` +
`Please check if the correct model is loaded/specified. ` +
`Currently loaded models are:\n${loadedModels}`,
);
this.name = "SpecifiedModelNotFoundError";
}
}
export class IncorrectPipelineLoadedError extends Error {
constructor(
selectedModelId: string,
expectedPipeline: string,
requestName: string,
) {
super(
`${requestName} expects model to be loaded with ${expectedPipeline}. However, ` +
`${selectedModelId} is not loaded with this pipeline.`,
);
this.name = "IncorrectPipelineLoadedError";
}
}
export class ReloadModelIdNotUniqueError extends Error {
constructor(modelId: string[]) {
super(
`Need to make models in modelId passed to reload() need to be unique. If you want to, ` +
`load copies of the same model, consider making copies of the ModelRecord with ` +
`different model_id. Received modelId: ${modelId}`,
);
this.name = "ReloadModelIdNotUniqueError";
}
}
export class IntegrityError extends Error {
constructor(
readonly url: string,
readonly expected: string,
readonly actual: string,
) {
super(
`Integrity verification failed for ${url}\n` +
` Expected: ${expected}\n` +
` Actual: ${actual}\n` +
`This may indicate file corruption or tampering.`,
);
this.name = "IntegrityError";
}
}
+195
View File
@@ -0,0 +1,195 @@
import * as tvmjs from "@mlc-ai/web-runtime";
import log from "loglevel";
import { ChatOptions, MLCEngineConfig } from "./config";
import { ReloadParams, WorkerRequest } from "./message";
import {
ChatWorker,
WebWorkerMLCEngineHandler,
WebWorkerMLCEngine,
} from "./web_worker";
import { areArraysEqual, areChatOptionsListEqual } from "./utils";
import { WebGPUNotFoundError } from "./error";
export interface ExtensionMLCEngineConfig extends MLCEngineConfig {
extensionId?: string;
onDisconnect?: () => void;
}
/**
* Worker handler that can be used in a ServiceWorker.
*
* @example
*
* const engine = new MLCEngine();
* let handler;
* chrome.runtime.onConnect.addListener(function (port) {
* if (handler === undefined) {
* handler = new ServiceWorkerMLCEngineHandler(engine, port);
* } else {
* handler.setPort(port);
* }
* port.onMessage.addListener(handler.onmessage.bind(handler));
* });
*/
export class ServiceWorkerMLCEngineHandler extends WebWorkerMLCEngineHandler {
port: chrome.runtime.Port | null;
constructor(port: chrome.runtime.Port) {
super();
this.port = port;
port.onDisconnect.addListener(() => this.onPortDisconnect(port));
}
postMessage(msg: any) {
this.port?.postMessage(msg);
}
setPort(port: chrome.runtime.Port) {
this.port = port;
port.onDisconnect.addListener(() => this.onPortDisconnect(port));
}
onPortDisconnect(port: chrome.runtime.Port) {
if (port === this.port) {
this.port = null;
}
}
onmessage(event: any): void {
if (event.type === "keepAlive") {
return;
}
const msg = event as WorkerRequest;
if (msg.kind === "reload") {
this.handleTask(msg.uuid, async () => {
const params = msg.content as ReloadParams;
// If the modelId, chatOpts, and appConfig are the same, immediately return
if (
areArraysEqual(this.modelId, params.modelId) &&
areChatOptionsListEqual(this.chatOpts, params.chatOpts)
) {
log.info("Already loaded the model. Skip loading");
const gpuDetectOutput = await tvmjs.detectGPUDevice();
if (gpuDetectOutput == undefined) {
throw new WebGPUNotFoundError();
}
let gpuLabel = "WebGPU";
if (gpuDetectOutput.adapterInfo.description.length != 0) {
gpuLabel += " - " + gpuDetectOutput.adapterInfo.description;
} else {
gpuLabel += " - " + gpuDetectOutput.adapterInfo.vendor;
}
this.engine.getInitProgressCallback()?.({
progress: 1,
timeElapsed: 0,
text: "Finish loading on " + gpuLabel,
});
return null;
}
await this.engine.reload(params.modelId, params.chatOpts);
this.modelId = params.modelId;
this.chatOpts = params.chatOpts;
return null;
});
return;
}
// All rest of message handling are the same as WebWorkerMLCEngineHandler
super.onmessage(event);
}
}
/**
* Create a ServiceWorkerMLCEngine.
*
* @param modelId model_id of the model to load, either string or string[]. When multiple models
* are provided, we load all models sequentially. Each modelId needs to either be in
* `webllm.prebuiltAppConfig`, or in `engineCOnfig.appConfig`.
* @param engineConfig Optionally configures the engine, see `webllm.MLCEngineConfig` for more.
* @param chatOpts Extra options to optionally override the `mlc-chat-config.json` of `modelId`.
* The size of which needs to match that of `modelId`; chatOpts[i] will be used for modelId[i].
* @param keepAliveMs The interval to send keep alive messages to the service worker.
* See [Service worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle#idle-shutdown)
* The default is 10s.
* @returns An initialized `WebLLM.ServiceWorkerMLCEngine` with `modelId` loaded.
*/
export async function CreateServiceWorkerMLCEngine(
modelId: string | string[],
engineConfig?: ExtensionMLCEngineConfig,
chatOpts?: ChatOptions | ChatOptions[],
keepAliveMs = 10000,
): Promise<ServiceWorkerMLCEngine> {
const serviceWorkerMLCEngine = new ServiceWorkerMLCEngine(
engineConfig,
keepAliveMs,
);
await serviceWorkerMLCEngine.reload(modelId, chatOpts);
return serviceWorkerMLCEngine;
}
class PortAdapter implements ChatWorker {
port: chrome.runtime.Port;
private _onmessage!: (message: any) => void;
constructor(port: chrome.runtime.Port) {
this.port = port;
this.port.onMessage.addListener(this.handleMessage.bind(this));
}
// Wrapper to handle incoming messages and delegate to onmessage if available
private handleMessage(message: any) {
if (this._onmessage) {
this._onmessage(message);
}
}
// Getter and setter for onmessage to manage adding/removing listeners
get onmessage(): (message: any) => void {
return this._onmessage;
}
set onmessage(listener: (message: any) => void) {
this._onmessage = listener;
}
// Wrap port.postMessage to maintain 'this' context
postMessage = (message: any): void => {
this.port.postMessage(message);
};
}
/**
* A client of MLCEngine that exposes the same interface
*/
export class ServiceWorkerMLCEngine extends WebWorkerMLCEngine {
port: chrome.runtime.Port;
extensionId?: string;
constructor(engineConfig?: ExtensionMLCEngineConfig, keepAliveMs = 10000) {
const extensionId = engineConfig?.extensionId;
const onDisconnect = engineConfig?.onDisconnect;
const port = extensionId
? chrome.runtime.connect(extensionId, {
name: "web_llm_service_worker",
})
: chrome.runtime.connect({ name: "web_llm_service_worker" });
const chatWorker = new PortAdapter(port);
super(chatWorker, engineConfig);
this.port = port;
this.extensionId = extensionId;
// Keep alive through periodical heartbeat signals
const keepAliveTimer = setInterval(() => {
this.worker.postMessage({ kind: "keepAlive" });
}, keepAliveMs);
port.onDisconnect.addListener(() => {
clearInterval(keepAliveTimer);
if (onDisconnect) {
onDisconnect();
}
});
}
}
+63
View File
@@ -0,0 +1,63 @@
export {
ModelRecord,
AppConfig,
OPFSAccessMode,
ChatOptions,
MLCEngineConfig,
GenerationConfig,
ModelType,
prebuiltAppConfig,
modelVersion,
modelLibURLPrefix,
functionCallingModelIds,
} from "./config";
export {
verifyIntegrity,
isValidSRI,
type ModelIntegrity,
type SRIString,
type FileIntegrityMap,
} from "./integrity";
export { IntegrityError } from "./error";
export {
InitProgressCallback,
InitProgressReport,
MLCEngineInterface,
LogitProcessor,
LogLevel,
} from "./types";
export { MLCEngine, CreateMLCEngine } from "./engine";
export {
hasModelInCache,
deleteChatConfigInCache,
deleteModelAllInfoInCache,
deleteModelWasmInCache,
deleteModelInCache,
} from "./cache_util";
export {
WebWorkerMLCEngineHandler,
WebWorkerMLCEngine,
CreateWebWorkerMLCEngine,
} from "./web_worker";
export { WorkerRequest, WorkerResponse, CustomRequestParams } from "./message";
export {
ServiceWorkerMLCEngineHandler,
ServiceWorkerMLCEngine,
CreateServiceWorkerMLCEngine,
} from "./service_worker";
export {
ServiceWorkerMLCEngineHandler as ExtensionServiceWorkerMLCEngineHandler,
ServiceWorkerMLCEngine as ExtensionServiceWorkerMLCEngine,
CreateServiceWorkerMLCEngine as CreateExtensionServiceWorkerMLCEngine,
} from "./extension_service_worker";
export * from "./openai_api_protocols/index";
+150
View File
@@ -0,0 +1,150 @@
import log from "loglevel";
import { IntegrityError } from "./error";
/**
* SRI (Subresource Integrity) hash string.
* Format: "sha256-BASE64", "sha384-BASE64", or "sha512-BASE64".
* See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
*/
export type SRIString = string;
/** Map of filename to SRI hash for per-file verification. */
export type FileIntegrityMap = Record<string, SRIString>;
/**
* Integrity configuration for a model's artifacts.
* All fields are optional — only specified artifacts will be verified.
*
* @param config SRI hash for the model's `mlc-chat-config.json`.
* @param model_lib SRI hash for the WASM model library file.
* @param tokenizer SRI hashes for tokenizer files, keyed by filename
* (e.g. `"tokenizer.json"` or `"tokenizer.model"`).
* @param onFailure Behavior on verification failure:
* `"error"` (default) throws an `IntegrityError`;
* `"warn"` logs a warning and continues.
*/
export interface ModelIntegrity {
config?: SRIString;
model_lib?: SRIString;
tokenizer?: FileIntegrityMap;
onFailure?: "error" | "warn";
}
type SRIAlgorithm = "sha256" | "sha384" | "sha512";
const SRI_REGEX = /^(sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/;
const SRI_HASH_BYTE_LENGTH: Record<SRIAlgorithm, number> = {
sha256: 32,
sha384: 48,
sha512: 64,
};
const ALGO_MAP: Record<SRIAlgorithm, string> = {
sha256: "SHA-256",
sha384: "SHA-384",
sha512: "SHA-512",
};
function getDecodedBase64ByteLength(base64: string): number | null {
const length = base64.length;
const remainder = length % 4;
if (remainder === 1) {
return null;
}
const paddingMatch = base64.match(/=+$/);
const padding = paddingMatch ? paddingMatch[0].length : 0;
if (padding > 2) {
return null;
}
if (padding > 0 && remainder !== 0) {
return null;
}
const fullQuartets = Math.floor(length / 4);
let decodedLength = fullQuartets * 3;
if (padding > 0) {
decodedLength -= padding;
} else if (remainder === 2) {
decodedLength += 1;
} else if (remainder === 3) {
decodedLength += 2;
}
return decodedLength;
}
function parseSRI(sri: string): { algo: SRIAlgorithm; hash: string } | null {
const match = sri.match(SRI_REGEX);
if (!match) {
return null;
}
const algo = match[1] as SRIAlgorithm;
const hash = match[2];
const decodedLength = getDecodedBase64ByteLength(hash);
if (decodedLength !== SRI_HASH_BYTE_LENGTH[algo]) {
return null;
}
return { algo, hash };
}
/**
* Verify an ArrayBuffer against an SRI hash using the Web Crypto API.
*
* @param data The raw bytes to verify.
* @param expectedSRI The expected SRI hash (e.g. `"sha256-abc123..."`).
* @param url The URL of the artifact, used for error messages.
* @param onFailure `"error"` to throw on mismatch, `"warn"` to log and continue.
* @throws {IntegrityError} When the hash does not match and `onFailure` is `"error"`.
*/
export async function verifyIntegrity(
data: ArrayBuffer,
expectedSRI: SRIString,
url: string,
onFailure: "error" | "warn" = "error",
): Promise<void> {
const parsed = parseSRI(expectedSRI);
if (!parsed) {
throw new Error(
`Invalid SRI hash format: "${expectedSRI}". ` +
`Expected format: "sha256-BASE64", "sha384-BASE64", or "sha512-BASE64".`,
);
}
const { algo, hash: expectedHash } = parsed;
const hashBuffer = await crypto.subtle.digest(ALGO_MAP[algo], data);
const hashArray = new Uint8Array(hashBuffer);
// Convert to base64
let binary = "";
for (let i = 0; i < hashArray.length; i++) {
binary += String.fromCharCode(hashArray[i]);
}
const actualHash = btoa(binary);
if (actualHash !== expectedHash) {
const actualSRI = `${algo}-${actualHash}`;
if (onFailure === "warn") {
log.warn(
`Integrity check failed for ${url}. ` +
`Expected: ${expectedSRI}, Got: ${actualSRI}`,
);
return;
}
throw new IntegrityError(url, expectedSRI, actualSRI);
}
}
/**
* Validate that a string is a well-formed SRI hash.
*
* @param sri The string to validate.
* @returns `true` if `sri` matches the SRI format.
*/
export function isValidSRI(sri: string): boolean {
return parseSRI(sri) !== null;
}
+2286
View File
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
import { AppConfig, ChatOptions } from "./config";
import { InitProgressReport, LogLevel } from "./types";
import {
ChatCompletionRequestStreaming,
ChatCompletionRequestNonStreaming,
ChatCompletion,
ChatCompletionChunk,
CompletionCreateParamsNonStreaming,
CompletionCreateParamsStreaming,
Completion,
EmbeddingCreateParams,
CreateEmbeddingResponse,
} from "./openai_api_protocols/index";
/**
* Message kind used by worker
*/
type RequestKind =
| "reload"
| "runtimeStatsText"
| "interruptGenerate"
| "unload"
| "resetChat"
| "getMaxStorageBufferBindingSize"
| "getGPUVendor"
| "forwardTokensAndSample"
| "chatCompletionNonStreaming"
| "completionNonStreaming"
| "embedding"
| "getMessage"
| "chatCompletionStreamInit"
| "completionStreamInit"
| "completionStreamNextChunk"
| "customRequest"
| "keepAlive"
| "setLogLevel"
| "setAppConfig";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type ResponseKind = "return" | "throw" | "initProgressCallback";
export interface ReloadParams {
modelId: string[];
chatOpts?: ChatOptions[];
}
export interface ResetChatParams {
keepStats: boolean;
modelId?: string;
}
export interface GetMessageParams {
modelId?: string;
}
export interface RuntimeStatsTextParams {
modelId?: string;
}
export interface ForwardTokensAndSampleParams {
inputIds: Array<number>;
isPrefill: boolean;
modelId?: string;
}
// Notes on the following Params with modelId and chatOpts:
// These fields are the model and chatOpts that the frontend engine expects the backend
// to be loaded with. If not loaded due to web/service worker unexpectedly killed,
// handler will call reload(). An engine can load multiple models, hence both are list.
// TODO(webllm-team): should add appConfig here as well if rigorous.
// Fore more, see https://github.com/mlc-ai/web-llm/pull/471
// Note on the messages with selectedModelId:
// This is the modelId this request uses. It is needed to identify which async generator
// to instantiate / use, since an engine can load multiple models, thus the handler
// needs to maintain multiple generators.
export interface ChatCompletionNonStreamingParams {
request: ChatCompletionRequestNonStreaming;
modelId: string[];
chatOpts?: ChatOptions[];
}
export interface ChatCompletionStreamInitParams {
request: ChatCompletionRequestStreaming;
selectedModelId: string;
modelId: string[];
chatOpts?: ChatOptions[];
}
export interface CompletionNonStreamingParams {
request: CompletionCreateParamsNonStreaming;
modelId: string[];
chatOpts?: ChatOptions[];
}
export interface CompletionStreamInitParams {
request: CompletionCreateParamsStreaming;
selectedModelId: string;
modelId: string[];
chatOpts?: ChatOptions[];
}
export interface EmbeddingParams {
request: EmbeddingCreateParams;
modelId: string[];
chatOpts?: ChatOptions[];
}
export interface CompletionStreamNextChunkParams {
selectedModelId: string;
}
export interface CustomRequestParams {
requestName: string;
requestMessage: string;
}
export type MessageContent =
| ReloadParams
| ResetChatParams
| GetMessageParams
| RuntimeStatsTextParams
| ForwardTokensAndSampleParams
| ChatCompletionNonStreamingParams
| ChatCompletionStreamInitParams
| CompletionNonStreamingParams
| CompletionStreamInitParams
| EmbeddingParams
| CompletionStreamNextChunkParams
| CustomRequestParams
| InitProgressReport
| LogLevel
| string
| null
| number
| ChatCompletion
| ChatCompletionChunk
| CreateEmbeddingResponse
| Completion
| AppConfig
| void;
/**
* The message used in exchange between worker
* and the main thread.
*/
export type WorkerRequest = {
kind: RequestKind;
uuid: string;
content: MessageContent;
};
type HeartbeatWorkerResponse = {
kind: "heartbeat";
uuid: string;
};
type OneTimeWorkerResponse = {
kind: "return" | "throw";
uuid: string;
content: MessageContent;
};
type InitProgressWorkerResponse = {
kind: "initProgressCallback";
uuid: string;
content: InitProgressReport;
};
export type WorkerResponse =
| OneTimeWorkerResponse
| InitProgressWorkerResponse
| HeartbeatWorkerResponse;
File diff suppressed because it is too large Load Diff
+381
View File
@@ -0,0 +1,381 @@
/**
* The input to OpenAI API, directly adopted from openai-node with small tweaks:
* https://github.com/openai/openai-node/blob/master/src/resources/completions.ts
*
* Copyright 2024 OpenAI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MLCEngineInterface } from "../types";
import {
InvalidStreamOptionsError,
SeedTypeError,
StreamingCountError,
UnsupportedFieldsError,
} from "../error";
import {
ChatCompletion,
ChatCompletionStreamOptions,
CompletionUsage,
ChatCompletionFinishReason,
} from "./chat_completion";
export class Completions {
private engine: MLCEngineInterface;
constructor(engine: MLCEngineInterface) {
this.engine = engine;
}
create(request: CompletionCreateParamsNonStreaming): Promise<Completion>;
create(
request: CompletionCreateParamsStreaming,
): Promise<AsyncIterable<Completion>>;
create(
request: CompletionCreateParamsBase,
): Promise<AsyncIterable<Completion> | Completion>;
create(
request: CompletionCreateParams,
): Promise<AsyncIterable<Completion> | Completion> {
return this.engine.completion(request);
}
}
//////////////////////////////// 1. CREATE PARAMS ////////////////////////////////
/**
* OpenAI completion request protocol.
*
* API reference: https://platform.openai.com/docs/api-reference/completions/create
* Followed: https://github.com/openai/openai-node/blob/master/src/resources/completions.ts
*
* @note `model` is excluded. Instead, call `CreateMLCEngine(model)` or `engine.reload(model)` explicitly before calling this API.
*/
export interface CompletionCreateParamsBase {
/**
* The prompt(s) to generate completions for, encoded as a string.
*/
prompt: string;
/**
* Echo back the prompt in addition to the completion
*/
echo?: boolean | null;
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on their
* existing frequency in the text so far, decreasing the model's likelihood to
* repeat the same line verbatim.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/text-generation/parameter-details)
*/
frequency_penalty?: number | null;
/**
* Modify the likelihood of specified tokens appearing in the completion.
*
* Accepts a JSON object that maps tokens (specified by their token ID, which varies per model)
* to an associated bias value from -100 to 100. Typically, you can see `tokenizer.json` of the
* model to see which token ID maps to what string. Mathematically, the bias is added to the
* logits generated by the model prior to sampling. The exact effect will vary per model, but
* values between -1 and 1 should decrease or increase likelihood of selection; values like -100
* or 100 should result in a ban or exclusive selection of the relevant token.
*
* As an example, you can pass `{"16230": -100}` to prevent the `Hello` token from being
* generated in Mistral-7B-Instruct-v0.2, according to the mapping in
* https://huggingface.co/mistralai/Mistral-7B-Instruct-v0.2/raw/main/tokenizer.json.
*
* @note For stateful and customizable / flexible logit processing, see `webllm.LogitProcessor`.
* @note If used in combination with `webllm.LogitProcessor`, `logit_bias` is applied after
* `LogitProcessor.processLogits()` is called.
*/
logit_bias?: Record<string, number> | null;
/**
* Whether to return log probabilities of the output tokens or not.
*
* If true, returns the log probabilities of each output token returned in the `content` of
* `message`.
*/
logprobs?: boolean | null;
/**
* An integer between 0 and 5 specifying the number of most likely tokens to return
* at each token position, each with an associated log probability. `logprobs` must
* be set to `true` if this parameter is used.
*/
top_logprobs?: number | null;
/**
* The maximum number of [tokens](/tokenizer) that can be generated in the
* completion.
*
* The total length of input tokens and generated tokens is limited by the model's
* context length.
*/
max_tokens?: number | null;
/**
* How many completions to generate for each prompt.
*/
n?: number | null;
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on
* whether they appear in the text so far, increasing the model's likelihood to
* talk about new topics.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/text-generation/parameter-details)
*/
presence_penalty?: number | null;
/**
* Penalizes new tokens based on whether they appear in the prompt and the
* generated text so far. Values greater than 1.0 encourage the model to use new
* tokens, while values less than 1.0 encourage the model to repeat tokens.
*/
repetition_penalty?: number | null;
/**
* If specified, our system will make a best effort to sample deterministically,
* such that repeated requests with the same `seed` and parameters should return
* the same result.
*
* @note Seeding is done on a request-level rather than choice-level. That is, if `n > 1`, you
* would still get different content for each `Choice`. But if two requests with `n = 2` are
* processed with the same seed, the two results should be the same (two choices are different).
*/
seed?: number | null;
/**
* Up to 4 sequences where the API will stop generating further tokens. The
* returned text will not contain the stop sequence.
*/
stop?: string | null | Array<string>;
/**
* If set, partial deltas will be sent. It will be terminated by an empty chunk.
*/
stream?: boolean | null;
/**
* Options for streaming response. Only set this when you set `stream: true`.
*/
stream_options?: ChatCompletionStreamOptions | null;
/**
* What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
* make the output more random, while lower values like 0.2 will make it more
* focused and deterministic.
*
* We generally recommend altering this or `top_p` but not both.
*/
temperature?: number | null;
/**
* An alternative to sampling with temperature, called nucleus sampling, where the
* model considers the results of the tokens with top_p probability mass. So 0.1
* means only the tokens comprising the top 10% probability mass are considered.
*
* We generally recommend altering this or `temperature` but not both.
*/
top_p?: number | null;
/**
* If true, will ignore stop string and stop token and generate until max_tokens hit.
* If unset, will treat as false.
*/
ignore_eos?: boolean;
/**
* ID of the model to use. This equals to `ModelRecord.model_id`, which needs to either be in
* `webllm.prebuiltAppConfig` or in `engineConfig.appConfig`.
*
* @note Call `CreateMLCEngine(model)` or `engine.reload(model)` ahead of time.
* @note If only one model is loaded in the engine, this field is optional. If multiple models
* are loaded, this is required.
*/
model?: string | null;
//////////////// BELOW FIELDS NOT SUPPORTED YET ////////////////
/**
* The suffix that comes after a completion of inserted text.
*
* @note This field is not supported.
*/
suffix?: string | null;
/**
* A unique identifier representing your end-user, which can help OpenAI to monitor
* and detect abuse.
*
* @note This field is not supported.
*/
user?: string;
/**
* Generates `best_of` completions server-side and returns the "best" (the one with
* the highest log probability per token). Results cannot be streamed.
*
* When used with `n`, `best_of` controls the number of candidate completions and
* `n` specifies how many to return `best_of` must be greater than `n`.
*
* @note This field is not supported.
*/
best_of?: number | null;
/**
* Fields specific to WebLLM, not present in OpenAI.
*/
extra_body?: {
/**
* If set to true, the response will include a breakdown of the time spent in various
* stages of token sampling.
*/
enable_latency_breakdown?: boolean | null;
};
}
export type CompletionCreateParams =
| CompletionCreateParamsNonStreaming
| CompletionCreateParamsStreaming;
export interface CompletionCreateParamsNonStreaming
extends CompletionCreateParamsBase {
/**
* If set, partial deltas will be sent. It will be terminated by an empty chunk.
*/
stream?: false | null;
}
export interface CompletionCreateParamsStreaming
extends CompletionCreateParamsBase {
/**
* If set, partial deltas will be sent. It will be terminated by an empty chunk.
*/
stream: true;
}
//////////////////////////////// 2. RESPONSE ////////////////////////////////
/**
* Represents a completion response returned by model, based on the provided input.
*/
export interface Completion {
/**
* A unique identifier for the completion.
*/
id: string;
/**
* The list of completion choices the model generated for the input prompt.
*/
choices: Array<CompletionChoice>;
/**
* The Unix timestamp (in seconds) of when the completion was created.
*/
created: number;
/**
* The model used for completion.
*/
model: string;
/**
* The object type, which is always "text_completion"
*/
object: "text_completion";
/**
* This fingerprint represents the backend configuration that the model runs with.
*
* Can be used in conjunction with the `seed` request parameter to understand when
* backend changes have been made that might impact determinism.
*
* @note Not supported yet.
*/
system_fingerprint?: string;
/**
* Usage statistics for the completion request.
*/
usage?: CompletionUsage;
}
export interface CompletionChoice {
/**
* The reason the model stopped generating tokens. This will be `stop` if the model
* hit a natural stop point or a provided stop sequence, or `length` if the maximum
* number of tokens specified in the request was reached.
*/
finish_reason: ChatCompletionFinishReason | null;
index: number;
/**
* A list of message content tokens with log probability information.
* @note Different from openai-node, we reuse ChatCompletion's Logprobs.
*/
logprobs?: ChatCompletion.Choice.Logprobs | null;
text: string;
}
//////////////////////////////// 3. POST INIT ////////////////////////////////
export const CompletionCreateParamsUnsupportedFields: Array<string> = [
"suffix",
"user",
"best_of",
];
/**
* Post init and verify whether the input of the request is valid. Thus, this function can throw
* error or in-place update request.
* @param request User's input request.
* @param currentModelId The current model loaded that will perform this request.
*/
export function postInitAndCheckFields(
request: CompletionCreateParams,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentModelId: string,
): void {
// 1. Check unsupported fields in request
const unsupported: Array<string> = [];
CompletionCreateParamsUnsupportedFields.forEach((field) => {
if (field in request) {
unsupported.push(field);
}
});
if (unsupported.length > 0) {
throw new UnsupportedFieldsError(unsupported, "CompletionCreateParams");
}
// 2. If streaming, n cannot be > 1, since we cannot manage multiple sequences at once
if (request.stream && request.n && request.n > 1) {
throw new StreamingCountError();
}
// 3. Seed should be an integer
if (request.seed !== undefined && request.seed !== null) {
if (!Number.isInteger(request.seed)) {
throw new SeedTypeError(request.seed);
}
}
// 4. Only set stream_options when streaming
if (request.stream_options !== undefined && request.stream_options !== null) {
if (!request.stream) {
throw new InvalidStreamOptionsError();
}
}
}
+198
View File
@@ -0,0 +1,198 @@
/**
* The input to OpenAI API, directly adopted from openai-node with small tweaks:
* https://github.com/openai/openai-node/blob/master/src/resources/embeddings.ts
*
* Copyright 2024 OpenAI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
EmbeddingInputEmptyError,
EmbeddingUnsupportedEncodingFormatError,
UnsupportedFieldsError,
} from "../error";
import { MLCEngineInterface } from "../types";
export class Embeddings {
private engine: MLCEngineInterface;
constructor(engine: MLCEngineInterface) {
this.engine = engine;
}
/**
* Creates an embedding vector representing the input text.
*/
create(request: EmbeddingCreateParams): Promise<CreateEmbeddingResponse> {
return this.engine.embedding(request);
}
}
export interface CreateEmbeddingResponse {
/**
* The list of embeddings generated by the model.
*/
data: Array<Embedding>;
/**
* The name of the model used to generate the embedding.
*/
model: string;
/**
* The object type, which is always "list".
*/
object: "list";
/**
* The usage information for the request.
*/
usage: CreateEmbeddingResponse.Usage;
}
/* eslint-disable-next-line @typescript-eslint/no-namespace */
export namespace CreateEmbeddingResponse {
/**
* The usage information for the request.
*/
export interface Usage {
/**
* The number of tokens used by the prompt.
*/
prompt_tokens: number;
/**
* The total number of tokens used by the request.
*/
total_tokens: number;
/**
* Fields specific to WebLLM, not present in OpenAI.
*/
extra: {
/**
* Number of tokens per second for prefilling.
*/
prefill_tokens_per_s: number;
};
}
}
/**
* Represents an embedding vector returned by embedding endpoint.
*/
export interface Embedding {
/**
* The embedding vector, which is a list of floats. The length of vector depends on
* the model.
*/
embedding: Array<number>;
/**
* The index of the embedding in the list of embeddings.
*/
index: number;
/**
* The object type, which is always "embedding".
*/
object: "embedding";
}
export interface EmbeddingCreateParams {
/**
* Input text to embed, encoded as a string or array of tokens. To embed multiple
* inputs in a single request, pass an array of strings or array of token arrays.
* The input must not exceed the max input tokens for the model, and cannot be an empty string.
* If the batch size is too large, multiple forward of the will take place.
*/
input: string | Array<string> | Array<number> | Array<Array<number>>;
/**
* ID of the model to use. This equals to `ModelRecord.model_id`, which needs to either be in
* `webllm.prebuiltAppConfig` or in `engineConfig.appConfig`.
*
* @note Call `CreateMLCEngine(model)` or `engine.reload(model)` ahead of time.
* @note If only one model is loaded in the engine, this field is optional. If multiple models
* are loaded, this is required.
*/
model?: string | null;
/**
* The format to return the embeddings in.
*
* @note Currently only support `float`.
*/
encoding_format?: "float" | "base64";
// TODO: can support matryoshka embedding models in future, hence allow `dimensions` for those.
/**
* The number of dimensions the resulting output embeddings should have.
*
* @note Not supported.
*/
dimensions?: number;
/**
* A unique identifier representing your end-user, which can help OpenAI to monitor
* and detect abuse.
*
* @note Not supported.
*/
user?: string;
}
export const EmbeddingCreateParamsUnsupportedFields: Array<string> = [
"dimensions",
"user",
];
export function postInitAndCheckFields(
request: EmbeddingCreateParams,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentModelId: string,
): void {
// 1. Check unsupported fields in request
const unsupported: Array<string> = [];
EmbeddingCreateParamsUnsupportedFields.forEach((field) => {
if (field in request) {
unsupported.push(field);
}
});
if (unsupported.length > 0) {
throw new UnsupportedFieldsError(unsupported, "EmbeddingCreateParams");
}
// 2. Unsupported format
if (request.encoding_format == "base64") {
throw new EmbeddingUnsupportedEncodingFormatError();
}
// 3. Invalid input
const input = request.input;
if (typeof input === "string") {
if (input === "") throw new EmbeddingInputEmptyError();
} else {
// input instanceof Array
if (input.length === 0) {
// Array<number>
throw new EmbeddingInputEmptyError();
}
for (let i = 0; i < input.length; i++) {
const curInput = input[i];
if (typeof curInput !== "number") {
// Array<string>, Array<Array<number>>
if (curInput.length === 0) throw new EmbeddingInputEmptyError();
}
}
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* The input to OpenAI API, directly adopted from openai-node with small tweaks:
* https://github.com/openai/openai-node/blob/master/src/resources/chat/completions.ts
*
* Copyright 2024 OpenAI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
Chat,
ChatCompletionRequestBase,
ChatCompletionRequestNonStreaming,
ChatCompletionRequestStreaming,
ChatCompletionRequest,
ChatCompletion,
ChatCompletionChunk,
ChatCompletionRequestUnsupportedFields,
postInitAndCheckFields as postInitAndCheckFieldsChatCompletion,
ChatCompletionContentPart,
ChatCompletionContentPartText,
ChatCompletionContentPartImage,
ChatCompletionMessageToolCall,
ChatCompletionRole,
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam,
ChatCompletionAssistantMessageParam,
ChatCompletionToolMessageParam,
ChatCompletionMessageParam,
FunctionParameters,
FunctionDefinition,
ChatCompletionTool,
ChatCompletionNamedToolChoice,
ChatCompletionToolChoiceOption,
TopLogprob,
ChatCompletionTokenLogprob,
ChatCompletionMessage,
CompletionUsage,
ResponseFormat,
ChatCompletionFinishReason,
} from "./chat_completion";
export {
Completions,
CompletionCreateParamsNonStreaming,
CompletionCreateParamsStreaming,
CompletionCreateParamsBase,
CompletionCreateParams,
Completion,
CompletionChoice,
postInitAndCheckFields as postInitAndCheckFieldsCompletion,
} from "./completion";
export {
Embeddings,
Embedding,
EmbeddingCreateParams,
CreateEmbeddingResponse,
postInitAndCheckFields as postInitAndCheckFieldsEmbedding,
} from "./embedding";
+253
View File
@@ -0,0 +1,253 @@
import * as tvmjs from "@mlc-ai/web-runtime";
import log from "loglevel";
import { ChatOptions, MLCEngineConfig } from "./config";
import { ReloadParams, WorkerRequest, WorkerResponse } from "./message";
import { InitProgressReport } from "./types";
import {
WebWorkerMLCEngineHandler,
WebWorkerMLCEngine,
ChatWorker,
} from "./web_worker";
import { areArraysEqual, areChatOptionsListEqual } from "./utils";
import {
NoServiceWorkerAPIError,
NonWorkerEnvironmentError,
ServiceWorkerInitializationError,
} from "./error";
/* Service Worker Script */
type IServiceWorker = globalThis.ServiceWorker;
/**
* Worker handler that can be used in a ServiceWorker.
*
* @example
*
* const engine = new MLCEngine();
* let handler;
* chrome.runtime.onConnect.addListener(function (port) {
* if (handler === undefined) {
* handler = new ServiceWorkerMLCEngineHandler(engine, port);
* } else {
* handler.setPort(port);
* }
* port.onMessage.addListener(handler.onmessage.bind(handler));
* });
*/
export class ServiceWorkerMLCEngineHandler extends WebWorkerMLCEngineHandler {
private clientRegistry = new Map<
string,
IServiceWorker | Client | MessagePort
>();
private initRequestUuid?: string;
constructor() {
if (!self || !("addEventListener" in self)) {
throw new NonWorkerEnvironmentError("ServiceWorkerMLCEngineHandler");
}
super();
const onmessage = this.onmessage.bind(this);
this.engine.setInitProgressCallback((report: InitProgressReport) => {
const msg: WorkerResponse = {
kind: "initProgressCallback",
uuid: this.initRequestUuid || "",
content: report,
};
this.postMessage(msg);
});
self.addEventListener("message", (event) => {
const message = event as unknown as ExtendableMessageEvent;
if (message.source) {
this.clientRegistry.set(message.data.uuid, message.source);
}
message.waitUntil(
new Promise((resolve, reject) => {
onmessage(message, resolve, reject);
}),
);
});
}
postMessage(message: WorkerResponse) {
if (this.clientRegistry.has(message.uuid)) {
const client = this.clientRegistry.get(message.uuid);
client?.postMessage(message);
if (message.kind === "return" || message.kind === "throw") {
this.clientRegistry.delete(message.uuid);
} else {
// TODO(nestor): Delete clientRegistry after complete to avoid memory leak?
}
}
}
onmessage(
event: ExtendableMessageEvent,
onComplete?: (value: any) => void,
onError?: () => void,
): void {
const msg = event.data as WorkerRequest;
log.trace(
`ServiceWorker message: [${msg.kind}] ${JSON.stringify(msg.content)}`,
);
// Special case message handling different from WebWorkerMLCEngineHandler
if (msg.kind === "keepAlive") {
const reply: WorkerResponse = {
kind: "heartbeat",
uuid: msg.uuid,
};
this.postMessage(reply);
onComplete?.(reply);
return;
}
if (msg.kind === "reload") {
this.handleTask(msg.uuid, async () => {
const params = msg.content as ReloadParams;
// If the modelId, chatOpts, and appConfig are the same, immediately return
if (
areArraysEqual(this.modelId, params.modelId) &&
areChatOptionsListEqual(this.chatOpts, params.chatOpts)
) {
log.info("Already loaded the model. Skip loading");
const gpuDetectOutput = await tvmjs.detectGPUDevice();
if (gpuDetectOutput == undefined) {
throw Error("Cannot find WebGPU in the environment");
}
let gpuLabel = "WebGPU";
if (gpuDetectOutput.adapterInfo.description.length != 0) {
gpuLabel += " - " + gpuDetectOutput.adapterInfo.description;
} else {
gpuLabel += " - " + gpuDetectOutput.adapterInfo.vendor;
}
this.engine.getInitProgressCallback()?.({
progress: 1,
timeElapsed: 0,
text: "Finish loading on " + gpuLabel,
});
onComplete?.(null);
return null;
}
this.initRequestUuid = msg.uuid;
await this.engine.reload(params.modelId, params.chatOpts);
this.modelId = params.modelId;
this.chatOpts = params.chatOpts;
onComplete?.(null);
return null;
});
return;
}
// All rest of message handling are the same as WebWorkerMLCEngineHandler
super.onmessage(msg, onComplete, onError);
}
}
/* Webapp Client */
export class ServiceWorker implements ChatWorker {
_onmessage: (event: MessageEvent) => void = () => {};
get onmessage() {
return this._onmessage;
}
set onmessage(handler: (event: any) => void) {
this._onmessage = handler;
if (!("serviceWorker" in navigator)) {
throw new NoServiceWorkerAPIError();
}
(navigator.serviceWorker as ServiceWorkerContainer).onmessage = handler;
}
postMessage(message: WorkerRequest) {
if (!("serviceWorker" in navigator)) {
throw new NoServiceWorkerAPIError();
}
const serviceWorker = (navigator.serviceWorker as ServiceWorkerContainer)
.controller;
if (!serviceWorker) {
throw new Error("There is no active service worker");
}
serviceWorker.postMessage(message);
}
}
/**
* Create a ServiceWorkerMLCEngine.
*
* @param modelId model_id of the model to load, either string or string[]. When multiple models
* are provided, we load all models sequentially. Each modelId needs to either be in
* `webllm.prebuiltAppConfig`, or in `engineCOnfig.appConfig`.
* @param engineConfig Optionally configures the engine, see `webllm.MLCEngineConfig` for more.
* @param chatOpts Extra options to optionally override the `mlc-chat-config.json` of `modelId`.
* The size of which needs to match that of `modelId`; chatOpts[i] will be used for modelId[i].
* @returns An initialized `WebLLM.ServiceWorkerMLCEngine` with `modelId` loaded.
*/
export async function CreateServiceWorkerMLCEngine(
modelId: string | string[],
engineConfig?: MLCEngineConfig,
chatOpts?: ChatOptions | ChatOptions[],
keepAliveMs = 10000,
): Promise<ServiceWorkerMLCEngine> {
if (!("serviceWorker" in navigator)) {
throw new NoServiceWorkerAPIError();
}
const serviceWorkerAPI = navigator.serviceWorker as ServiceWorkerContainer;
const registration = await serviceWorkerAPI.ready;
const serviceWorker = registration.active || serviceWorkerAPI.controller;
if (!serviceWorker) {
throw new ServiceWorkerInitializationError();
}
const serviceWorkerMLCEngine = new ServiceWorkerMLCEngine(
engineConfig,
keepAliveMs,
);
await serviceWorkerMLCEngine.reload(modelId, chatOpts);
return serviceWorkerMLCEngine;
}
/**
* A client of MLCEngine that exposes the same interface
*/
export class ServiceWorkerMLCEngine extends WebWorkerMLCEngine {
missedHeartbeat = 0;
constructor(engineConfig?: MLCEngineConfig, keepAliveMs = 10000) {
if (!("serviceWorker" in navigator)) {
throw new NoServiceWorkerAPIError();
}
super(new ServiceWorker(), engineConfig);
// Keep alive through periodical heartbeat signals
setInterval(() => {
this.worker.postMessage({ kind: "keepAlive", uuid: crypto.randomUUID() });
this.missedHeartbeat += 1;
log.trace("missedHeartbeat", this.missedHeartbeat);
}, keepAliveMs);
}
onmessage(event: any): void {
const msg = event.data;
log.trace(
`MLC client message: [${msg.kind}] ${JSON.stringify(msg.content)}`,
);
try {
if (msg.kind === "heartbeat") {
this.missedHeartbeat = 0;
return;
}
super.onmessage(msg);
} catch (err: any) {
// This is expected to throw if user has multiple windows open
if (!err.message.startsWith("return from a unknown uuid")) {
log.error("CreateWebServiceWorkerMLCEngine.onmessage", err);
}
}
}
}
+449
View File
@@ -0,0 +1,449 @@
/** Util methods. */
import { Tokenizer } from "@mlc-ai/web-tokenizers";
import { AppConfig, MessagePlaceholders, ModelRecord } from "./config";
import {
ChatCompletionChunk,
ChatCompletionContentPartImage,
ChatCompletionMessageToolCall,
} from "./openai_api_protocols/index";
import {
ModelNotFoundError,
ModelNotLoadedError,
PrefillChunkSizeSmallerThanImageError,
SpecifiedModelNotFoundError,
ToolCallOutputInvalidTypeError,
ToolCallOutputMissingFieldsError,
ToolCallOutputParseError,
UnclearModelToUseError,
} from "./error";
/**
* Based on `p_prob` of size (vocabSize,) which becomes a distribution after calling
* `applySoftmaxWithTemperature()`, sample `top_logprobs` top-probable tokens.
*
* @param num_top_probs: `top_logprobs` from ChatCompletionRequest
* @param p_prob: `logitsOnCPUArray`, being a distribution after `applySoftmaxWithTemperature()`.
*
* Followed implementation of `ComputeTopProbsImpl()` from [https://github.com/mlc-ai/mlc-llm/blob/
* 5b8c529e9704abd09b0432da6dcb4b013fdf43b1/cpp/serve/sampler/cpu_sampler.cc].
*
* @returns Arrays of (tokenID, prob) pairs, ranked from highest prob to least.
*/
export function getTopProbs(
num_top_probs: number,
p_prob: Float32Array,
): Array<[number, number]> {
if (num_top_probs == 0) return [];
// Initialize to dummy values
const top_probs: Array<[number, number]> = [];
const ndata = p_prob.length;
for (let i = 0; i < num_top_probs; i++) {
top_probs.push([-1, -1.0]);
}
let sum_prob = 0.0;
// Selection argsort.
for (let p = 0; p < ndata; p++) {
let i = num_top_probs - 1;
for (; i >= 0; --i) {
if (p_prob[p] > top_probs[i][1]) {
if (i !== num_top_probs - 1) {
top_probs[i + 1] = top_probs[i];
}
} else {
break;
}
}
if (i !== num_top_probs - 1) {
top_probs[i + 1] = [p, p_prob[p]];
}
// Early exit
sum_prob += p_prob[p];
if (1 - sum_prob <= top_probs[num_top_probs - 1][1]) {
break;
}
}
return top_probs;
}
/**
* Get the token table in the form of a string list of tokens, ordered by their token id.
* @param tokenizer A loaded tokenizer.
* @note The size of the table (i.e. tokenizer.getVocabSize()) may be smaller than the `vocab_size`
* in config.json (length of logits), see https://github.com/QwenLM/Qwen2/issues/147 and
* https://huggingface.co/microsoft/Phi-3-mini-4k-instruct/discussions/47.
*/
export function getTokenTableFromTokenizer(tokenizer: Tokenizer): string[] {
const tokenTable: string[] = [];
const vocabSize = tokenizer.getVocabSize();
for (let tokenId = 0; tokenId < vocabSize; tokenId++) {
tokenTable.push(tokenizer.idToToken(tokenId));
}
return tokenTable;
}
/**
* Postprocess the suffix of ModelRecord.model to be "/resolve/main/" if it is not specified otherwise.
* e.g. https://huggingface.co/mlc-ai/OpenHermes-2.5-Mistral-7B-q4f16_1-MLC/resolve/main/
* @return the href of the final URL.
*/
export function cleanModelUrl(modelUrl: string): string {
// https://huggingface.co/USER/MODEL -> https://huggingface.co/USER/MODEL/
modelUrl += modelUrl.endsWith("/") ? "" : "/";
if (!modelUrl.match(/.+\/resolve\/.+\//)) modelUrl += "resolve/main/";
// https://huggingface.co/USER/MODEL/ -> https://huggingface.co/USER/MODEL/resolve/main/
return new URL(modelUrl).href;
}
// Constants for Hermes-2-Pro / Hermes-3 models function calling
// Follows https://huggingface.co/NousResearch/Hermes-2-Pro-Llama-3-8B#prompt-format-for-function-calling
// https://huggingface.co/NousResearch/Hermes-3-Llama-3.1-8B#prompt-format-for-function-calling
/**
* Json schema used to prompt the model for function calling; directly copied from the official guide.
* This represents to a single function call.
*/
export const officialHermes2FunctionCallSchema = `{"properties": {"arguments": {"title": "Arguments", "type": "object"}, "name": {"title": "Name", "type": "string"}}, "required": ["arguments", "name"], "title": "FunctionCall", "type": "object"}`;
/**
* A list of such function calls. Used to specify response format, since the output is expected to
* be a list of such function calls.
*/
export const officialHermes2FunctionCallSchemaArray = `{"type":"array","items":${officialHermes2FunctionCallSchema}}`;
/**
* Full system prompt for Hermes-2-Pro and Hermes-3 function calling.
*/
export const hermes2FunctionCallingSystemPrompt = `You are a function calling AI model. You are
provided with function signatures within <tools></tools> XML tags. You may call one or more functions
to assist with the user query. Don't make assumptions about what values to plug into functions. Here
are the available tools: <tools> ${MessagePlaceholders.hermes_tools} </tools>.
Use the following pydantic model json schema for each tool call you will make:
${officialHermes2FunctionCallSchema} For each function call return a json object.`;
/**
* Given a string outputMessage, parse it as a JSON object and return an array of tool calls.
*
* Expect outputMessage to be a valid JSON string, and expect it to be an array of Function with
* fields `arguments` and `name`.
*/
export function getToolCallFromOutputMessage(
outputMessage: string,
isStreaming: false,
): Array<ChatCompletionMessageToolCall>;
export function getToolCallFromOutputMessage(
outputMessage: string,
isStreaming: true,
): Array<ChatCompletionChunk.Choice.Delta.ToolCall>;
export function getToolCallFromOutputMessage(
outputMessage: string,
isStreaming: boolean,
):
| Array<ChatCompletionMessageToolCall>
| Array<ChatCompletionChunk.Choice.Delta.ToolCall> {
// 1. Parse outputMessage to JSON object
let toolCallsObject;
try {
toolCallsObject = JSON.parse(outputMessage);
} catch (err) {
throw new ToolCallOutputParseError(outputMessage, err as Error);
}
// 2. Expect to be an array
if (!(toolCallsObject instanceof Array)) {
throw new ToolCallOutputInvalidTypeError("array");
}
// 3. Parse each tool call and populate tool_calls
const numToolCalls = toolCallsObject.length;
const tool_calls = [];
for (let id = 0; id < numToolCalls; id++) {
const curToolCall = toolCallsObject[id];
if (curToolCall.name === undefined || curToolCall.arguments === undefined) {
throw new ToolCallOutputMissingFieldsError(
["name", "arguments"],
curToolCall,
);
}
tool_calls.push({
name: curToolCall.name,
arguments: JSON.stringify(curToolCall.arguments),
});
}
// 4. Return based on whether it is streaming or not
if (isStreaming) {
const tool_calls_result: Array<ChatCompletionChunk.Choice.Delta.ToolCall> =
[];
for (let id = 0; id < numToolCalls; id++) {
const curToolCall = tool_calls[id];
tool_calls_result.push({
index: id,
function: {
name: curToolCall.name,
arguments: curToolCall.arguments,
},
type: "function",
});
}
return tool_calls_result;
} else {
const tool_calls_result: Array<ChatCompletionMessageToolCall> = [];
for (let id = 0; id < numToolCalls; id++) {
const curToolCall = tool_calls[id];
tool_calls_result.push({
id: id.toString(),
function: {
name: curToolCall.name,
arguments: curToolCall.arguments,
},
type: "function",
});
}
return tool_calls_result;
}
}
export function findModelRecord(
modelId: string,
appConfig: AppConfig,
): ModelRecord {
const matchedItem = appConfig.model_list.find(
(item) => item.model_id == modelId,
);
if (matchedItem !== undefined) return matchedItem;
throw new ModelNotFoundError(modelId);
}
/**
* Return the model to use given the loaded modelIds and requestModel. Throws error when unclear
* which model to load.
* @param loadedModelIds Models currently loaded in the engine.
* @param requestModel Model the user specified to load via the request. Required when multiple
* models are loaded
* @param requestName The type of request or API to load the model for. Needed for error throwing.
*/
export function getModelIdToUse(
loadedModelIds: string[],
requestModel: string | undefined | null,
requestName: string,
): string {
let selectedModelId: string;
if (loadedModelIds.length === 0) {
throw new ModelNotLoadedError(requestName);
}
if (requestModel) {
// If specified model
if (loadedModelIds.indexOf(requestModel) === -1) {
throw new SpecifiedModelNotFoundError(
loadedModelIds,
requestModel,
requestName,
);
} else {
selectedModelId = requestModel;
}
} else {
// If not specified
if (loadedModelIds.length > 1) {
throw new UnclearModelToUseError(loadedModelIds, requestName);
} else {
selectedModelId = loadedModelIds[0];
}
}
return selectedModelId;
}
/**
* TODO: Consider if this is the best strategy (though aligned with mlc-llm). We currently greedily
* try to fill up prefillChunkSize. Consider the example with 2048 prefill chunk size:
* const inputData = [
image1, // 1921
rangeArr(0, 2048),
image2,
];
* Current approach results in chunks:
[image1, rangeArr(0, 127)],
[rangeArr(127, 2048)],
[image2],
* This means 4 embedding kernels and 3 prefill kernels.
* While the optimal chunking may be:
[image1],
[rangeArr(0, 2048)],
[image2],
* This results in 3 embedding kernels and 3 prefill kernels.
* However, greedy strategy is more intuitive and probably more generalizable.
*/
/**
* Chunk the inputData such that each chunk's total input length is smaller than prefill
* chunk size.
* @returns [the data chunks, the input length of each chunk]
* @note precondition: if inputData has image in it, then prefillChunkSize >= imageEmbedSize.
*/
export function getChunkedPrefillInputData(
inputData: Array<Array<number> | ImageURL>,
prefillChunkSize: number,
getImageEmbedSize: (image: ImageURL) => number,
): [Array<Array<number> | ImageURL>[], Array<number>] {
const chunks: Array<Array<number> | ImageURL>[] = [];
const chunkLens: Array<number> = [];
let curChunk: Array<Array<number> | ImageURL> = [];
let curChunkLen = 0;
for (let i = 0; i < inputData.length; i++) {
let curData: Array<number> | ImageURL = inputData[i];
const curDataLen = Array.isArray(curData)
? curData.length
: getImageEmbedSize(curData);
if (!Array.isArray(curData) && curDataLen > prefillChunkSize) {
throw new PrefillChunkSizeSmallerThanImageError(
prefillChunkSize,
curDataLen,
);
}
// 1. curData can fit into this chunk
if (curChunkLen + curDataLen <= prefillChunkSize) {
curChunk.push(curData);
curChunkLen += curDataLen;
if (curChunkLen === prefillChunkSize) {
chunks.push([...curChunk]);
chunkLens.push(curChunkLen);
curChunk = [];
curChunkLen = 0;
}
continue;
}
// 2. Otherwise, depends on whether it is token data or image data
if (Array.isArray(curData)) {
// 2.1. Token data, which itself needs to be chunked. Keep
// chunking and finalizing until finished
while (curData.length > 0) {
const curDataToChunkLen = Math.min(
curData.length,
prefillChunkSize - curChunkLen,
);
curChunk.push(curData.slice(0, curDataToChunkLen));
curChunkLen += curDataToChunkLen;
curData = curData.slice(curDataToChunkLen);
if (curChunkLen === prefillChunkSize) {
// curChunk is now full, so finalize to chunks
chunks.push([...curChunk]);
chunkLens.push(curChunkLen);
curChunk = [];
curChunkLen = 0;
}
}
} else {
// 2.2. Image data, which itself cannot be chunked, so cannot fit in current chunk.
// 2.2.1. Finalize curChunk
if (curChunk.length === 0) {
throw new Error(
"InternalError: do not expect curChunk to be empty when an image does not fit.",
);
}
chunks.push([...curChunk]);
chunkLens.push(curChunkLen);
// 2.2.2. Then push image to the new chunk
curChunk = [curData];
curChunkLen = curDataLen;
if (curChunkLen === prefillChunkSize) {
chunks.push([...curChunk]);
chunkLens.push(curChunkLen);
curChunk = [];
curChunkLen = 0;
}
}
}
// Last chunk
if (curChunk.length > 0) {
chunks.push([...curChunk]);
chunkLens.push(curChunkLen);
}
return [chunks, chunkLens];
}
type Cont = () => void;
/**
* A lock implemented using Promise.
*
* Referred to:
* - https://jackpordi.com/posts/locks-in-js-because-why-not
* - https://www.linkedin.com/pulse/asynchronous-locking-using-promises-javascript-abdul-ahad-o7smf/
*/
export class CustomLock {
private acquired = false;
private readonly queue: Cont[] = [];
public async acquire(): Promise<void> {
if (!this.acquired) {
// If lock is free, directly return
this.acquired = true;
} else {
// Otherwise, push the request to the queue, and
// a future release() will resolve it
return new Promise<void>((resolve) => {
this.queue.push(resolve);
});
}
}
public async release(): Promise<void> {
if (!this.acquired) {
throw Error("InternalError: expect lock is acquired upon release()");
}
if (this.queue.length === 0) {
// No one is waiting for the lock, so we free it
this.acquired = false;
return;
}
// Otherwise, hand the execution to the next in queue, and
// the lock is still acquired
const cont = this.queue.shift();
return new Promise((res: Cont) => {
cont!();
res();
});
}
}
// Image related
type ImageURL = ChatCompletionContentPartImage.ImageURL;
/**
* Given a url, get the image data. The url can either start with `http` or `data:image`.
*/
export async function getImageDataFromURL(url: string): Promise<ImageData> {
const response = await fetch(url, { mode: "cors" });
const img = await createImageBitmap(await response.blob());
const canvas = new OffscreenCanvas(img.width, img.height);
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not get 2d context");
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
return imageData;
}
/**
* Given an ImageData, return the RGB array in Uint8ClampedArray. Note the ImageData.data
* is RGBA, so we skip every fourth element of the data. The order goes by rows from the
* top-left pixel to the bottom-right, in RGB order.
*/
export function getRGBArrayFromImageData(
imageData: ImageData,
): Uint8ClampedArray {
const newData = new Uint8ClampedArray(imageData.width * imageData.height * 3);
for (let i = 0, offset = 0; i < imageData.data.length; i += 4) {
newData[offset++] = imageData.data[i];
newData[offset++] = imageData.data[i + 1];
newData[offset++] = imageData.data[i + 2];
}
return newData;
}
+262
View File
@@ -0,0 +1,262 @@
import { AppConfig, ChatOptions } from "./config";
import {
ChatCompletionRequest,
ChatCompletionRequestBase,
ChatCompletionRequestStreaming,
ChatCompletionRequestNonStreaming,
ChatCompletion,
ChatCompletionChunk,
CompletionCreateParams,
Completion,
CompletionCreateParamsBase,
CompletionCreateParamsStreaming,
CompletionCreateParamsNonStreaming,
EmbeddingCreateParams,
CreateEmbeddingResponse,
} from "./openai_api_protocols/index";
import * as API from "./openai_api_protocols/index";
/**
* Report during intialization.
*/
export interface InitProgressReport {
progress: number;
timeElapsed: number;
text: string;
}
/**
* Callbacks used to report initialization process.
*/
export type InitProgressCallback = (report: InitProgressReport) => void;
/**
* A stateful logitProcessor used to post-process logits after forwarding the input and before
* sampling the next token. If used with `GenerationConfig.logit_bias`, logit_bias is applied after
* `processLogits()` is called.
*/
export interface LogitProcessor {
/**
* Process logits after forward() and before sampling implicitly, happens on the CPU.
* @param logits The logits right after forward().
* Returns the processed logits.
*/
processLogits: (logits: Float32Array) => Float32Array;
/**
* Use the sampled token to update the LogitProcessor's internal state. Called implicitly
* right after the next token is sampled/committed.
* @param token Token sampled from the processed logits.
*/
processSampledToken: (token: number) => void;
/**
* Called when in `MLCEngine.resetChat()`. Can clear internal states.
*/
resetState: () => void;
}
/**
* Common interface of MLCEngine that UI can interact with
*/
export interface MLCEngineInterface {
/**
* An object that exposes chat-related APIs.
*/
chat: API.Chat;
/**
* An object that exposes text completion APIs.
*/
completions: API.Completions;
/**
* An object that exposes embeddings APIs.
*/
embeddings: API.Embeddings;
/**
* Set an initialization progress callback function
* which reports the progress of model loading.
*
* This function can be useful to implement an UI that
* update as we loading the model.
*
* @param initProgressCallback The callback function
*/
setInitProgressCallback: (initProgressCallback: InitProgressCallback) => void;
/**
* @returns The current initialization progress callback function.
*/
getInitProgressCallback: () => InitProgressCallback | undefined;
/**
* Setter for the engine's appConfig.
*/
setAppConfig: (appConfig: AppConfig) => void;
/**
* Reload the chat with a new model.
*
* @param modelId model_id of the model to load, either string or string[]. When multiple models
* are provided, we load all models sequentially. Each modelId needs to either be in
* `webllm.prebuiltAppConfig`, or in `engineConfig.appConfig`.
* @param chatOpts Extra options to optionally override the `mlc-chat-config.json` of `modelId`.
* The size of which needs to match that of `modelId`; chatOpts[i] will be used for modelId[i].
* @returns A promise when reload finishes.
* @throws Throws error when device lost (mostly due to OOM); users should re-call reload(),
* potentially with a smaller model or smaller context window size.
* @note This is an async function.
*/
reload: (
modelId: string | string[],
chatOpts?: ChatOptions | ChatOptions[],
) => Promise<void>;
/**
* OpenAI-style API. Generate a chat completion response for the given conversation and
* configuration. Use `engine.chat.completions.create()` to invoke this API.
*
* @param request A OpenAI-style ChatCompletion request.
*
* @note The API is completely functional in behavior. That is, a previous request would not
* affect the current request's result. Thus, for multi-round chatting, users are responsible for
* maintaining the chat history. With that being said, as an implicit internal optimization, if we
* detect that the user is performing multi-round chatting, we will preserve the KV cache and only
* prefill the new tokens.
* @note For requests sent to the same modelId, will block until all previous requests finish.
* @note For more, see https://platform.openai.com/docs/api-reference/chat
*/
chatCompletion(
request: ChatCompletionRequestNonStreaming,
): Promise<ChatCompletion>;
chatCompletion(
request: ChatCompletionRequestStreaming,
): Promise<AsyncIterable<ChatCompletionChunk>>;
chatCompletion(
request: ChatCompletionRequestBase,
): Promise<AsyncIterable<ChatCompletionChunk> | ChatCompletion>;
chatCompletion(
request: ChatCompletionRequest,
): Promise<AsyncIterable<ChatCompletionChunk> | ChatCompletion>;
/**
* OpenAI-style API. Completes a CompletionCreateParams, a text completion with no chat template.
* Use `engine.completions.create()` to invoke this API.
*
* @param request An OpenAI-style Completion request.
*
* @note For requests sent to the same modelId, will block until all previous requests finish.
* @note For more, see https://platform.openai.com/docs/api-reference/completions
*/
completion(request: CompletionCreateParamsNonStreaming): Promise<Completion>;
completion(
request: CompletionCreateParamsStreaming,
): Promise<AsyncIterable<Completion>>;
completion(
request: CompletionCreateParamsBase,
): Promise<AsyncIterable<Completion> | Completion>;
completion(
request: CompletionCreateParams,
): Promise<AsyncIterable<Completion> | Completion>;
/**
* OpenAI-style API. Creates an embedding vector representing the input text.
* Use `engine.embeddings.create()` to invoke this API.
*
* @param request An OpenAI-style Embeddings request.
*
* @note For requests sent to the same modelId, will block until all previous requests finish.
* @note For more, see https://platform.openai.com/docs/api-reference/embeddings/create
*/
embedding(request: EmbeddingCreateParams): Promise<CreateEmbeddingResponse>;
/**
* @returns A text summarizing the runtime stats.
* @param modelId Only required when multiple models are loaded.
* @note This is an async function
*/
runtimeStatsText: (modelId?: string) => Promise<string>;
/**
* Interrupt the generate process if it is already running.
*/
interruptGenerate: () => void;
/**
* Explicitly unload the currently loaded model(s) and release the related resources. Waits until
* the webgpu device finishes all submitted work and destroys itself.
* @note This is an asynchronous function.
*/
unload: () => Promise<void>;
/**
* Reset the current chat session by clear all memories.
* @param keepStats: If True, do not reset the statistics.
* @param modelId Only required when multiple models are loaded.
*/
resetChat: (keepStats?: boolean, modelId?: string) => Promise<void>;
/**
* Get the current generated response.
* @param modelId Only required when multiple models are loaded.
* @returns The current output message.
*/
getMessage: (modelId?: string) => Promise<string>;
/**
* Returns the device's maxStorageBufferBindingSize, can be used to guess whether the device
* has limited resources like an Android phone.
*/
getMaxStorageBufferBindingSize(): Promise<number>;
/**
* Returns the device's gpu vendor (e.g. arm, qualcomm, apple) if available. Otherwise return
* an empty string.
*/
getGPUVendor(): Promise<string>;
/**
* Forward the given input tokens to the model, then sample the next token.
*
* This function has side effects as the model will update its KV cache.
*
* @param inputIds The input tokens.
* @param isPrefill True if prefill, false if decode; only used for statistics.
* @param modelId Only required when multiple models are loaded.
* @returns Next token sampled.
* @note This is an async function.
*/
forwardTokensAndSample(
inputIds: Array<number>,
isPrefill: boolean,
modelId?: string,
): Promise<number>;
/**
* Set MLCEngine logging output level
*
* @param logLevel The new log level
*/
setLogLevel(logLevel: LogLevel): void;
}
export const LOG_LEVELS = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
SILENT: 5,
};
export type LogLevel = keyof typeof LOG_LEVELS;
export type LatencyBreakdown = {
logitProcessorTime: number[];
logitBiasTime: number[];
penaltyTime: number[];
sampleTime: number[];
totalTime: number[];
grammarBitmaskTime: number[];
};
+153
View File
@@ -0,0 +1,153 @@
import { AppConfig, ChatOptions, ModelRecord, getCacheBackend } from "./config";
// Helper function to compare two arrays
export function areArraysEqual(arr1?: Array<any>, arr2?: Array<any>): boolean {
if (!arr1 && !arr2) return true;
if (!arr1 || !arr2) return false;
if (arr1.length !== arr2.length) return false;
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) return false;
}
return true;
}
// Helper function to compare two objects deeply
function areObjectsEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (typeof obj1 !== typeof obj2) return false;
if (typeof obj1 !== "object" || obj1 === null || obj2 === null) return false;
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (!keys2.includes(key) || !areObjectsEqual(obj1[key], obj2[key]))
return false;
}
return true;
}
// Function to compare two ModelRecord instances
export function areModelRecordsEqual(
record1: ModelRecord,
record2: ModelRecord,
): boolean {
// Compare primitive fields
if (
record1.model !== record2.model ||
record1.model_id !== record2.model_id ||
record1.model_lib !== record2.model_lib ||
record1.vram_required_MB !== record2.vram_required_MB ||
record1.low_resource_required !== record2.low_resource_required ||
record1.buffer_size_required_bytes !== record2.buffer_size_required_bytes
) {
return false;
}
// Compare required_features arrays
if (
(record1.required_features && !record2.required_features) ||
(!record1.required_features && record2.required_features)
) {
return false;
}
if (record1.required_features && record2.required_features) {
if (record1.required_features.length !== record2.required_features.length) {
return false;
}
for (let i = 0; i < record1.required_features.length; i++) {
if (record1.required_features[i] !== record2.required_features[i]) {
return false;
}
}
}
return true;
}
export function areAppConfigsEqual(
config1?: AppConfig,
config2?: AppConfig,
): boolean {
if (config1 === undefined || config2 === undefined) {
return config1 === config2;
}
// Check if both configurations have the same cache backend
if (getCacheBackend(config1) !== getCacheBackend(config2)) {
return false;
}
if (
getCacheBackend(config1) === "opfs" &&
(config1.opfsAccessMode ?? "async") !== (config2.opfsAccessMode ?? "async")
) {
return false;
}
// Check if both configurations have the same number of model records
if (config1.model_list.length !== config2.model_list.length) {
return false;
}
// Compare each ModelRecord in the model_list
for (let i = 0; i < config1.model_list.length; i++) {
if (!areModelRecordsEqual(config1.model_list[i], config2.model_list[i])) {
return false;
}
}
// If all checks passed, the configurations are equal
return true;
}
export function areChatOptionsEqual(
options1?: ChatOptions,
options2?: ChatOptions,
): boolean {
if (options1 === undefined || options2 === undefined) {
return options1 === options2;
}
// Compare each property of ChatOptions (which are Partial<ChatConfig>)
if (!areArraysEqual(options1.tokenizer_files, options2.tokenizer_files))
return false;
if (!areObjectsEqual(options1.conv_config, options2.conv_config))
return false;
if (options1.conv_template !== options2.conv_template) return false;
if (options1.repetition_penalty !== options2.repetition_penalty) return false;
if (options1.frequency_penalty !== options2.frequency_penalty) return false;
if (options1.presence_penalty !== options2.presence_penalty) return false;
if (options1.top_p !== options2.top_p) return false;
if (options1.temperature !== options2.temperature) return false;
if (options1.bos_token_id !== options2.bos_token_id) return false;
// If all checks passed, the options are equal
return true;
}
export function areChatOptionsListEqual(
options1?: ChatOptions[],
options2?: ChatOptions[],
): boolean {
if (options1 && options2) {
// Both defined, need to compare
if (options1.length !== options2.length) {
return false;
} else {
for (let i = 0; i < options1.length; i++) {
if (!areChatOptionsEqual(options1[i], options2[i])) {
return false;
}
}
return true;
}
} else if (!options1 && !options2) {
// Both undefined, equal
return true;
} else {
// One defined, other not
return false;
}
}
+842
View File
@@ -0,0 +1,842 @@
import { AppConfig, ChatOptions, MLCEngineConfig } from "./config";
import {
MLCEngineInterface,
InitProgressCallback,
InitProgressReport,
LogLevel,
LogitProcessor,
} from "./types";
import {
ChatCompletionRequest,
ChatCompletionRequestBase,
ChatCompletionRequestStreaming,
ChatCompletionRequestNonStreaming,
ChatCompletion,
ChatCompletionChunk,
Completion,
CompletionCreateParamsNonStreaming,
CompletionCreateParamsStreaming,
CompletionCreateParamsBase,
CompletionCreateParams,
CreateEmbeddingResponse,
EmbeddingCreateParams,
} from "./openai_api_protocols/index";
import * as API from "./openai_api_protocols/index";
import {
MessageContent,
ReloadParams,
ForwardTokensAndSampleParams,
ChatCompletionNonStreamingParams,
ChatCompletionStreamInitParams,
ResetChatParams,
WorkerResponse,
WorkerRequest,
CompletionNonStreamingParams,
EmbeddingParams,
CompletionStreamInitParams,
GetMessageParams,
RuntimeStatsTextParams,
CompletionStreamNextChunkParams,
} from "./message";
import log from "loglevel";
import { MLCEngine } from "./engine";
import {
UnknownMessageKindError,
WorkerEngineModelNotLoadedError,
} from "./error";
import { areArraysEqual } from "./utils";
import { getModelIdToUse } from "./support";
/**
* Worker handler that can be used in a WebWorker
*
* @example
*
* // setup a chat worker handler that routes
* // requests to the chat
* const engine = new MLCEngine();
* cont handler = new WebWorkerMLCEngineHandler(engine);
* onmessage = handler.onmessage;
*/
export class WebWorkerMLCEngineHandler {
/**
* The modelId and chatOpts that the underlying engine (backend) is currently loaded with.
* An engine can be loaded with multiple models, so modelId and chatOpts are lists.
*
* TODO(webllm-team): This is always in-sync with `this.engine` unless device is lost due to
* unexpected reason. Therefore, we should get it from `this.engine` directly and make handler
* stateless. Besides, consider if we should add appConfig, or use engine's API to find the
* corresponding model record rather than relying on just the modelId.
*/
modelId?: string[];
chatOpts?: ChatOptions[];
public engine: MLCEngine;
/** ChatCompletion and Completion share the same chunk generator. Each loaded model has its own. */
protected loadedModelIdToAsyncGenerator: Map<
string,
AsyncGenerator<ChatCompletionChunk | Completion, void, void>
>;
/**
* @param engine A concrete implementation of MLCEngineInterface
*/
constructor() {
this.engine = new MLCEngine();
this.loadedModelIdToAsyncGenerator = new Map<
string,
AsyncGenerator<ChatCompletionChunk | Completion, void, void>
>();
this.engine.setInitProgressCallback((report: InitProgressReport) => {
const msg: WorkerResponse = {
kind: "initProgressCallback",
uuid: "",
content: report,
};
this.postMessage(msg);
});
}
postMessage(msg: any) {
// Use Web Worker DOM Message API
postMessage(msg);
}
setLogitProcessorRegistry(
logitProcessorRegistry?: Map<string, LogitProcessor>,
) {
this.engine.setLogitProcessorRegistry(logitProcessorRegistry);
}
async handleTask<T extends MessageContent>(
uuid: string,
task: () => Promise<T>,
) {
try {
const res = await task();
const msg: WorkerResponse = {
kind: "return",
uuid: uuid,
content: res,
};
this.postMessage(msg);
} catch (err) {
const errStr = (err as object).toString();
const msg: WorkerResponse = {
kind: "throw",
uuid: uuid,
content: errStr,
};
this.postMessage(msg);
}
}
onmessage(
event: any,
onComplete?: (value: any) => void,
onError?: () => void,
) {
let msg: WorkerRequest;
if (event instanceof MessageEvent) {
msg = event.data as WorkerRequest;
} else {
msg = event as WorkerRequest;
}
switch (msg.kind) {
case "reload": {
this.handleTask(msg.uuid, async () => {
const params = msg.content as ReloadParams;
await this.engine.reload(params.modelId, params.chatOpts);
this.modelId = params.modelId;
this.chatOpts = params.chatOpts;
onComplete?.(null);
return null;
});
return;
}
case "forwardTokensAndSample": {
this.handleTask(msg.uuid, async () => {
const params = msg.content as ForwardTokensAndSampleParams;
const res = await this.engine.forwardTokensAndSample(
params.inputIds,
params.isPrefill,
params.modelId,
);
onComplete?.(res);
return res;
});
return;
}
// For engine.chat.completions.create()
case "chatCompletionNonStreaming": {
// Directly return the ChatCompletion response
this.handleTask(msg.uuid, async () => {
const params = msg.content as ChatCompletionNonStreamingParams;
await this.reloadIfUnmatched(params.modelId, params.chatOpts);
const res = await this.engine.chatCompletion(params.request);
onComplete?.(res);
return res;
});
return;
}
case "chatCompletionStreamInit": {
// One-time set up that instantiates the chunk generator in worker
this.handleTask(msg.uuid, async () => {
const params = msg.content as ChatCompletionStreamInitParams;
// Also ensures params.selectedModelId will match what this.engine selects
await this.reloadIfUnmatched(params.modelId, params.chatOpts);
// Register new async generator for this new request of the model
const curGenerator = (await this.engine.chatCompletion(
params.request,
)) as AsyncGenerator<ChatCompletionChunk, void, void>;
this.loadedModelIdToAsyncGenerator.set(
params.selectedModelId,
curGenerator,
);
onComplete?.(null);
return null;
});
return;
}
// For engine.completions.create()
case "completionNonStreaming": {
// Directly return the ChatCompletion response
this.handleTask(msg.uuid, async () => {
const params = msg.content as CompletionNonStreamingParams;
await this.reloadIfUnmatched(params.modelId, params.chatOpts);
const res = await this.engine.completion(params.request);
onComplete?.(res);
return res;
});
return;
}
case "completionStreamInit": {
// One-time set up that instantiates the chunk generator in worker
this.handleTask(msg.uuid, async () => {
const params = msg.content as CompletionStreamInitParams;
// Also ensures params.selectedModelId will match what this.engine selects
await this.reloadIfUnmatched(params.modelId, params.chatOpts);
// Register new async generator for this new request of the model
const curGenerator = (await this.engine.completion(
params.request,
)) as AsyncGenerator<Completion, void, void>;
this.loadedModelIdToAsyncGenerator.set(
params.selectedModelId,
curGenerator,
);
onComplete?.(null);
return null;
});
return;
}
// Shared by engine.chat.completions.create() and engine.completions.create()
case "completionStreamNextChunk": {
// Note: ChatCompletion and Completion share the same chunk generator.
// For any subsequent request, we return whatever `next()` yields
this.handleTask(msg.uuid, async () => {
const params = msg.content as CompletionStreamNextChunkParams;
const curGenerator = this.loadedModelIdToAsyncGenerator.get(
params.selectedModelId,
);
if (curGenerator === undefined) {
throw Error(
"InternalError: Chunk generator in worker should be instantiated by now.",
);
}
// Yield the next chunk
const { value } = await curGenerator.next();
onComplete?.(value);
return value;
});
return;
}
// For engine.embeddings.create()
case "embedding": {
// Directly return the Embeddings response
this.handleTask(msg.uuid, async () => {
const params = msg.content as EmbeddingParams;
await this.reloadIfUnmatched(params.modelId, params.chatOpts);
const res = await this.engine.embedding(params.request);
onComplete?.(res);
return res;
});
return;
}
case "runtimeStatsText": {
this.handleTask(msg.uuid, async () => {
const params = msg.content as RuntimeStatsTextParams;
const res = await this.engine.runtimeStatsText(params.modelId);
onComplete?.(res);
return res;
});
return;
}
case "interruptGenerate": {
this.handleTask(msg.uuid, async () => {
this.engine.interruptGenerate();
onComplete?.(null);
return null;
});
return;
}
case "unload": {
// Unset modelId and chatOpts since backend unloads the model
this.handleTask(msg.uuid, async () => {
await this.engine.unload();
this.modelId = undefined;
this.chatOpts = undefined;
// This may not be cleaned properly when one asyncGenerator finishes.
// We only clear at unload(), which may not be called upon reload().
// However, service_worker may skip reload(). Will leave as is for now.
this.loadedModelIdToAsyncGenerator.clear();
onComplete?.(null);
return null;
});
return;
}
case "resetChat": {
this.handleTask(msg.uuid, async () => {
const params = msg.content as ResetChatParams;
await this.engine.resetChat(params.keepStats, params.modelId);
onComplete?.(null);
return null;
});
return;
}
case "getMaxStorageBufferBindingSize": {
this.handleTask(msg.uuid, async () => {
const res = await this.engine.getMaxStorageBufferBindingSize();
onComplete?.(res);
return res;
});
return;
}
case "getGPUVendor": {
this.handleTask(msg.uuid, async () => {
const res = await this.engine.getGPUVendor();
onComplete?.(res);
return res;
});
return;
}
case "getMessage": {
this.handleTask(msg.uuid, async () => {
const params = msg.content as GetMessageParams;
const res = await this.engine.getMessage(params.modelId);
onComplete?.(res);
return res;
});
return;
}
case "setLogLevel": {
const logLevel = msg.content as LogLevel;
this.engine.setLogLevel(logLevel);
log.setLevel(logLevel);
onComplete?.(null);
return;
}
case "setAppConfig": {
const appConfig = msg.content as AppConfig;
this.engine.setAppConfig(appConfig);
onComplete?.(null);
return;
}
case "customRequest": {
onComplete?.(null);
return;
}
default: {
if (msg.kind && msg.content) {
onError?.();
throw new UnknownMessageKindError(msg.kind, msg.content);
} else {
// Ignore irrelavent events
onComplete?.(null);
}
}
}
}
/** Check whether frontend expectation matches with backend (modelId and chatOpts). If not (due
* to possibly killed service worker), we reload here.
* For more, see https://github.com/mlc-ai/web-llm/pull/533
*/
async reloadIfUnmatched(
expectedModelId: string[],
expectedChatOpts?: ChatOptions[],
) {
// TODO: should we also check expectedChatOpts here?
if (!areArraysEqual(this.modelId, expectedModelId)) {
log.warn(
"WebWorkerMLCEngine expects model is loaded in WebWorkerMLCEngineHandler, " +
"but it is not. This may due to web/service worker is unexpectedly killed.\n" +
"Reloading engine in WebWorkerMLCEngineHandler.",
);
await this.engine.reload(expectedModelId, expectedChatOpts);
}
}
}
export interface ChatWorker {
onmessage: any;
postMessage: (message: any) => void;
}
/**
* Creates `WebWorkerMLCEngine`, a client that holds the same interface as `MLCEngine`.
*
* Equivalent to `new webllm.WebWorkerMLCEngine(worker).reload(...)`.
*
* @param worker The worker that holds the actual MLCEngine, initialized with `new Worker()`.
* @param modelId model_id of the model to load, either string or string[]. When multiple models
* are provided, we load all models sequentially. Each modelId needs to either be in
* `webllm.prebuiltAppConfig`, or in `engineCOnfig.appConfig`.
* @param engineConfig Optionally configures the engine, see `webllm.MLCEngineConfig` for more.
* @param chatOpts Extra options to optionally override the `mlc-chat-config.json` of `modelId`.
* The size of which needs to match that of `modelId`; chatOpts[i] will be used for modelId[i].
* @returns An initialized `WebLLM.WebWorkerMLCEngine` with `modelId` loaded.
*
* @note engineConfig.logitProcessorRegistry is ignored for `CreateWebWorkMLCEngine()`.
*/
export async function CreateWebWorkerMLCEngine(
worker: any,
modelId: string | string[],
engineConfig?: MLCEngineConfig,
chatOpts?: ChatOptions | ChatOptions[],
): Promise<WebWorkerMLCEngine> {
const webWorkerMLCEngine = new WebWorkerMLCEngine(worker, engineConfig);
await webWorkerMLCEngine.reload(modelId, chatOpts);
return webWorkerMLCEngine;
}
/**
* A client of MLCEngine that exposes the same interface
*
* @example
*
* const chat = new webllm.WebWorkerMLCEngine(new Worker(
* new URL('./worker.ts', import.meta.url),
* {type: 'module'}
* ));
*/
export class WebWorkerMLCEngine implements MLCEngineInterface {
public worker: ChatWorker;
/** For chat.completions.create() */
public chat: API.Chat;
/** For completions.create() */
public completions: API.Completions;
/** For embeddings.create() */
public embeddings: API.Embeddings;
/**
* The modelId and chatOpts that the frontend expects the backend engine is currently loaded
* with. Needed for service worker. It is the backend and handler's job to match up with the
* expectation despite the web/service worker possibly being killed.
* Since an engine can load multiple models, both modelId and chatOpts are lists.
*/
modelId?: string[];
chatOpts?: ChatOptions[];
private initProgressCallback?: InitProgressCallback;
private pendingPromise = new Map<string, (msg: WorkerResponse) => void>();
constructor(worker: ChatWorker, engineConfig?: MLCEngineConfig) {
this.worker = worker;
worker.onmessage = (event: any) => {
this.onmessage.bind(this)(event);
};
if (engineConfig?.appConfig) {
this.setAppConfig(engineConfig?.appConfig);
}
if (engineConfig?.logLevel) {
this.setLogLevel(engineConfig?.logLevel);
}
this.setInitProgressCallback(engineConfig?.initProgressCallback);
if (engineConfig?.logitProcessorRegistry) {
if (engineConfig?.logitProcessorRegistry) {
log.warn(
"Warning: The `logitProcessorRegistry` property in `engineConfig` will be ignored when using the WebWorkerMLCEngine constructor. To set `logitProcessorRegistry`, use the engine constructor within the worker script instead.",
);
}
}
this.chat = new API.Chat(this);
this.completions = new API.Completions(this);
this.embeddings = new API.Embeddings(this);
}
setInitProgressCallback(initProgressCallback?: InitProgressCallback) {
this.initProgressCallback = initProgressCallback;
}
getInitProgressCallback(): InitProgressCallback | undefined {
return this.initProgressCallback;
}
setAppConfig(appConfig: AppConfig) {
const msg: WorkerRequest = {
kind: "setAppConfig",
uuid: crypto.randomUUID(),
content: appConfig,
};
this.worker.postMessage(msg);
}
setLogLevel(logLevel: LogLevel) {
log.setLevel(logLevel);
const msg: WorkerRequest = {
kind: "setLogLevel",
uuid: crypto.randomUUID(),
content: logLevel,
};
this.worker.postMessage(msg);
}
protected getPromise<T extends MessageContent>(
msg: WorkerRequest,
): Promise<T> {
const uuid = msg.uuid;
const executor = (
resolve: (arg: T) => void,
reject: (arg: any) => void,
) => {
const cb = (msg: WorkerResponse) => {
if (msg.kind == "return") {
resolve(msg.content as T);
} else {
if (msg.kind != "throw") {
reject("Uknown msg kind " + msg.kind);
} else {
reject(msg.content);
}
}
};
this.pendingPromise.set(uuid, cb);
};
const promise = new Promise<T>(executor);
this.worker.postMessage(msg);
return promise;
}
async reload(
modelId: string | string[],
chatOpts?: ChatOptions | ChatOptions[],
): Promise<void> {
// Always convert modelId and chatOpts to lists internally for ease of manipulation
if (!Array.isArray(modelId)) {
modelId = [modelId];
}
if (chatOpts !== undefined && !Array.isArray(chatOpts)) {
chatOpts = [chatOpts];
}
const msg: WorkerRequest = {
kind: "reload",
uuid: crypto.randomUUID(),
content: {
modelId: modelId,
chatOpts: chatOpts,
},
};
await this.getPromise<null>(msg);
this.modelId = modelId;
this.chatOpts = chatOpts;
}
async getMaxStorageBufferBindingSize(): Promise<number> {
const msg: WorkerRequest = {
kind: "getMaxStorageBufferBindingSize",
uuid: crypto.randomUUID(),
content: null,
};
return await this.getPromise<number>(msg);
}
async getGPUVendor(): Promise<string> {
const msg: WorkerRequest = {
kind: "getGPUVendor",
uuid: crypto.randomUUID(),
content: null,
};
return await this.getPromise<string>(msg);
}
async getMessage(modelId?: string): Promise<string> {
const msg: WorkerRequest = {
kind: "getMessage",
uuid: crypto.randomUUID(),
content: {
modelId: modelId,
},
};
return await this.getPromise<string>(msg);
}
async runtimeStatsText(modelId?: string): Promise<string> {
const msg: WorkerRequest = {
kind: "runtimeStatsText",
uuid: crypto.randomUUID(),
content: {
modelId: modelId,
},
};
return await this.getPromise<string>(msg);
}
interruptGenerate(): void {
const msg: WorkerRequest = {
kind: "interruptGenerate",
uuid: crypto.randomUUID(),
content: null,
};
this.getPromise<null>(msg);
}
async unload(): Promise<void> {
const msg: WorkerRequest = {
kind: "unload",
uuid: crypto.randomUUID(),
content: null,
};
await this.getPromise<null>(msg);
this.modelId = undefined;
this.chatOpts = undefined;
}
async resetChat(keepStats = false, modelId?: string): Promise<void> {
const msg: WorkerRequest = {
kind: "resetChat",
uuid: crypto.randomUUID(),
content: {
keepStats: keepStats,
modelId: modelId,
},
};
await this.getPromise<null>(msg);
}
async forwardTokensAndSample(
inputIds: Array<number>,
isPrefill: boolean,
modelId?: string,
): Promise<number> {
const msg: WorkerRequest = {
kind: "forwardTokensAndSample",
uuid: crypto.randomUUID(),
content: {
inputIds: inputIds,
isPrefill: isPrefill,
modelId: modelId,
},
};
return await this.getPromise<number>(msg);
}
/**
* Every time the generator is called, we post a message to the worker asking it to
* decode one step, and we expect to receive a message of `ChatCompletionChunk` from
* the worker which we yield. The last message is `void`, meaning the generator has nothing
* to yield anymore.
*
* @param selectedModelId: The model of whose async generator to call next() to get next chunk.
* Needed because an engine can load multiple models.
*
* @note ChatCompletion and Completion share the same chunk generator.
*/
async *asyncGenerate(
selectedModelId: string,
): AsyncGenerator<ChatCompletionChunk | Completion, void, void> {
// Every time it gets called, sends message to worker, asking for the next chunk
while (true) {
const msg: WorkerRequest = {
kind: "completionStreamNextChunk",
uuid: crypto.randomUUID(),
content: {
selectedModelId: selectedModelId,
} as CompletionStreamNextChunkParams,
};
const ret = await this.getPromise<ChatCompletionChunk>(msg);
// If the worker's generator reached the end, it would return a `void`
if (typeof ret !== "object") {
break;
}
yield ret;
}
}
async chatCompletion(
request: ChatCompletionRequestNonStreaming,
): Promise<ChatCompletion>;
async chatCompletion(
request: ChatCompletionRequestStreaming,
): Promise<AsyncIterable<ChatCompletionChunk>>;
async chatCompletion(
request: ChatCompletionRequestBase,
): Promise<AsyncIterable<ChatCompletionChunk> | ChatCompletion>;
async chatCompletion(
request: ChatCompletionRequest,
): Promise<AsyncIterable<ChatCompletionChunk> | ChatCompletion> {
if (this.modelId === undefined) {
throw new WorkerEngineModelNotLoadedError(this.constructor.name);
}
// Needed for the streaming case. Consolidate model id to specify
// which model's asyncGenerator to instantiate or call next() on.
// Since handler can maintain multiple generators concurrently
const selectedModelId = getModelIdToUse(
this.modelId ? this.modelId : [],
request.model,
"ChatCompletionRequest",
);
if (request.stream) {
// First let worker instantiate a generator
const msg: WorkerRequest = {
kind: "chatCompletionStreamInit",
uuid: crypto.randomUUID(),
content: {
request: request,
selectedModelId: selectedModelId,
modelId: this.modelId,
chatOpts: this.chatOpts,
},
};
await this.getPromise<null>(msg);
// Then return an async chunk generator that resides on the client side
return this.asyncGenerate(selectedModelId) as AsyncGenerator<
ChatCompletionChunk,
void,
void
>;
}
// Non streaming case is more straightforward
const msg: WorkerRequest = {
kind: "chatCompletionNonStreaming",
uuid: crypto.randomUUID(),
content: {
request: request,
modelId: this.modelId,
chatOpts: this.chatOpts,
},
};
return await this.getPromise<ChatCompletion>(msg);
}
async completion(
request: CompletionCreateParamsNonStreaming,
): Promise<Completion>;
async completion(
request: CompletionCreateParamsStreaming,
): Promise<AsyncIterable<Completion>>;
async completion(
request: CompletionCreateParamsBase,
): Promise<AsyncIterable<Completion> | Completion>;
async completion(
request: CompletionCreateParams,
): Promise<AsyncIterable<Completion> | Completion> {
if (this.modelId === undefined) {
throw new WorkerEngineModelNotLoadedError(this.constructor.name);
}
// Needed for the streaming case. Consolidate model id to specify
// which model's asyncGenerator to instantiate or call next() on.
// Since handler can maintain multiple generators concurrently
const selectedModelId = getModelIdToUse(
this.modelId ? this.modelId : [],
request.model,
"CompletionCreateParams",
);
if (request.stream) {
// First let worker instantiate a generator
const msg: WorkerRequest = {
kind: "completionStreamInit",
uuid: crypto.randomUUID(),
content: {
request: request,
selectedModelId: selectedModelId,
modelId: this.modelId,
chatOpts: this.chatOpts,
},
};
await this.getPromise<null>(msg);
// Then return an async chunk generator that resides on the client side
return this.asyncGenerate(selectedModelId) as AsyncGenerator<
Completion,
void,
void
>;
}
// Non streaming case is more straightforward
const msg: WorkerRequest = {
kind: "completionNonStreaming",
uuid: crypto.randomUUID(),
content: {
request: request,
modelId: this.modelId,
chatOpts: this.chatOpts,
},
};
return await this.getPromise<Completion>(msg);
}
async embedding(
request: EmbeddingCreateParams,
): Promise<CreateEmbeddingResponse> {
if (this.modelId === undefined) {
throw new WorkerEngineModelNotLoadedError(this.constructor.name);
}
const msg: WorkerRequest = {
kind: "embedding",
uuid: crypto.randomUUID(),
content: {
request: request,
modelId: this.modelId,
chatOpts: this.chatOpts,
},
};
return await this.getPromise<CreateEmbeddingResponse>(msg);
}
onmessage(event: any) {
let msg: WorkerResponse;
if (event instanceof MessageEvent) {
msg = event.data as WorkerResponse;
} else {
msg = event as WorkerResponse;
}
switch (msg.kind) {
case "initProgressCallback": {
if (this.initProgressCallback !== undefined) {
this.initProgressCallback(msg.content as InitProgressReport);
}
return;
}
case "return": {
const cb = this.pendingPromise.get(msg.uuid);
if (cb === undefined) {
throw Error("return from a unknown uuid msg=" + msg.uuid);
}
this.pendingPromise.delete(msg.uuid);
cb(msg);
return;
}
case "throw": {
const cb = this.pendingPromise.get(msg.uuid);
if (cb === undefined) {
throw Error("return from a unknown uuid, msg=" + msg);
}
this.pendingPromise.delete(msg.uuid);
cb(msg);
return;
}
default: {
const unknownMsg = msg as any;
throw new UnknownMessageKindError(unknownMsg.kind, unknownMsg.content);
}
}
}
}