Files
sousa-gecko/toolkit/components/ml/content/Utils.sys.mjs
T
2026-01-17 18:54:31 +00:00

1729 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
// @ts-nocheck - TODO - Remove this to type check this file.
/**
* @import { TypedArray } from "../ml.d.ts"
*/
const lazy = {};
const IN_WORKER = typeof importScripts !== "undefined";
const ES_MODULES_OPTIONS = IN_WORKER ? { global: "current" } : {};
ChromeUtils.defineESModuleGetters(
lazy,
{
BLOCK_WORDS_ENCODED: "chrome://global/content/ml/BlockWords.sys.mjs",
ModelHub: "chrome://global/content/ml/ModelHub.sys.mjs",
MLEngine: "resource://gre/actors/MLEngineParent.sys.mjs",
EngineProcess: "chrome://global/content/ml/EngineProcess.sys.mjs",
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs",
FEATURES: "chrome://global/content/ml/EngineProcess.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
},
ES_MODULES_OPTIONS
);
/**
* Log level set by the pipeline.
*
* @type {string}
*/
let logLevel = "Error";
/**
* Sets the log level.
*
* @param {string} level - The log level.
*/
export function setLogLevel(level) {
logLevel = level;
}
if (IN_WORKER) {
ChromeUtils.defineLazyGetter(lazy, "console", () => {
return console.createInstance({
maxLogLevel: logLevel, // we can't use maxLogLevelPref in workers.
prefix: "GeckoMLUtils",
});
});
} else {
ChromeUtils.defineLazyGetter(lazy, "console", () => {
return console.createInstance({
maxLogLevelPref: "browser.ml.logLevel",
prefix: "GeckoMLUtils",
});
});
}
/** The name of the remote settings collection holding block list */
const RS_BLOCK_LIST_COLLECTION = "ml-inference-words-block-list";
/**
* Enumeration for the progress status text.
*/
export const ProgressStatusText = Object.freeze({
// The value of the status text indicating that an operation is started.
INITIATE: "initiate",
// The value of the status text indicating an estimate for the size of the operation.
SIZE_ESTIMATE: "size_estimate",
// The value of the status text indicating that an operation is in progress.
IN_PROGRESS: "in_progress",
// The value of the status text indicating that an operation has completed.
DONE: "done",
});
/**
* Enumeration for type of progress operations.
*/
export const ProgressType = Object.freeze({
// The value of the operation type for a remote downloading.
DOWNLOAD: "downloading",
// The value of the operation type when loading from cache
LOAD_FROM_CACHE: "loading_from_cache",
// The value of the operation type when running the model
INFERENCE: "running_inference",
});
/**
* This class encapsulates the parameters supported by a progress and status callback.
*/
export class ProgressAndStatusCallbackParams {
// Params for progress callback
/**
* A float indicating the percentage of data loaded. Note that
* 100% does not necessarily mean the operation is complete.
*
* @type {?float}
*/
progress = null;
/**
* A float indicating the total amount of data loaded so far.
* In particular, this is the sum of currentLoaded across all call of the callback.
*
* @type {?float}
*/
totalLoaded = null;
/**
* The amount of data loaded in the current callback call.
*
* @type {?float}
*/
currentLoaded = null;
/**
* A float indicating an estimate of the total amount of data to be loaded.
* Do not rely on this number as this is an estimate and the true total could be
* either lower or higher.
*
* @type {?float}
*/
total = null;
/**
* The units in which the amounts are reported.
*
* @type {?string}
*/
units = null;
// Params for status callback
/**
* The name of the operation being tracked.
*
* @type {?string}
*/
type = null;
/**
* A message indicating the status of the tracked operation.
*
* @type {?string}
*/
statusText = null;
/**
* An ID uniquely identifying the object/file being tracked.
*
* @type {?string}
*/
id = null;
/**
* A boolean indicating if the operation was successful.
* true means we have a successful operation.
*
* @type {?boolean}
*/
ok = null;
/**
* Any additional metadata for the operation being tracked.
*
* @type {?object}
*/
metadata = null;
constructor(params = {}) {
this.update(params);
}
update(params = {}) {
const allowedKeys = new Set(Object.keys(this));
const invalidKeys = Object.keys(params).filter(x => !allowedKeys.has(x));
if (invalidKeys.length) {
throw new Error(`Received Invalid option: ${invalidKeys}`);
}
for (const key of allowedKeys) {
if (key in params) {
this[key] = params[key];
}
}
}
}
/**
* Creates the file URL from the organization, model, and version.
*
* @param {object} config - The configuration object to be updated.
* @param {string} config.model - model name
* @param {string} config.revision - model revision
* @param {string} config.file - filename
* @param {string} config.rootUrl - root url of the model hub
* @param {string} config.urlTemplate - url template of the model hub
* @param {boolean} [config.addDownloadParams] - Whether to add a download query parameter.
* @returns {string} The full URL
*/
export function createFileUrl({
model,
revision,
file,
rootUrl,
urlTemplate,
addDownloadParams = false,
}) {
const baseUrl = new URL(rootUrl);
if (!baseUrl.pathname.endsWith("/")) {
baseUrl.pathname += "/";
}
// Replace placeholders in the URL template with the provided data.
// If some keys are missing in the data object, the placeholder is left as is.
// If the placeholder is not found in the data object, it is left as is.
const data = {
model,
revision,
};
let path = urlTemplate.replace(
/\{(\w+)\}/g,
(match, key) => data[key] || match
);
path = `${path}/${file}`;
const fullPath = `${baseUrl.pathname}${
path.startsWith("/") ? path.slice(1) : path
}`;
const urlObject = new URL(fullPath, baseUrl.origin);
if (addDownloadParams) {
urlObject.searchParams.append("download", "true");
}
return urlObject.toString();
}
/**
* Read and track progress when reading a Response object
*
* @param {any} response The Response object to read
* @param {?function(ProgressAndStatusCallbackParams):void} progressCallback The function to call with progress updates
*
* @returns {Promise<Uint8Array>} A Promise that resolves with the Uint8Array buffer
*/
export async function readResponse(response, progressCallback) {
const contentLength = response.headers.get("Content-Length");
if (!contentLength) {
console.warn(
"Unable to determine content-length from response headers. Will expand buffer when needed."
);
}
let total = parseInt(contentLength ?? "0");
progressCallback?.(
new ProgressAndStatusCallbackParams({
progress: 0,
totalLoaded: 0,
currentLoaded: 0,
total,
units: "bytes",
})
);
let buffer = new Uint8Array(total);
let loaded = 0;
for await (const value of response.body) {
let newLoaded = loaded + value.length;
if (newLoaded > total) {
total = newLoaded;
// Adding the new data will overflow buffer.
// In this case, we extend the buffer
// Happened when the content-length is lower than the actual lenght
let newBuffer = new Uint8Array(total);
// copy contents
newBuffer.set(buffer);
buffer = newBuffer;
}
buffer.set(value, loaded);
loaded = newLoaded;
const progress = (loaded / total) * 100;
progressCallback?.(
new ProgressAndStatusCallbackParams({
progress,
totalLoaded: loaded,
currentLoaded: value.length,
total,
units: "bytes",
})
);
}
// Ensure that buffer is not bigger than loaded
// Sometimes content length is larger than the actual size
buffer = buffer.slice(0, loaded);
return buffer;
}
/**
* Class for watching the progress bar of multiple events and combining
* then into a single progress bar.
*/
export class MultiProgressAggregator {
/**
* A function to call with the aggregated statistics.
*
* @type {?function(ProgressAndStatusCallbackParams):void}
*/
progressCallback = null;
/**
* The name of the key that contains status information.
*
* @type {Set<string>}
*/
watchedTypes;
/**
* The number of operations that are yet to be completed.
*
* @type {float}
*/
#remainingEvents = 0;
/**
* The type of operation seen so far.
*
* @type {Set<string>}
*/
#seenTypes;
/**
* Total number of objects seen, irrespective of method
*
* @type {integer}
*/
#totalObjectsSeen = 0;
/**
* The status of text seen so far.
*
* @type {Set<string>}
*/
#seenStatus;
/**
* Info about each object.
*
* @type {Dict<string, integer>}
*/
#downloadObjects;
/**
* @param {object} config
* @param {?function(ProgressAndStatusCallbackParams):void} config.progressCallback - A function to call with the aggregated statistics.
* @param {Iterable<string>} config.watchedTypes - The types to watch for aggregation
*/
constructor({ progressCallback, watchedTypes = [ProgressType.DOWNLOAD] }) {
this.progressCallback = progressCallback;
this.watchedTypes = new Set(watchedTypes);
this.#seenTypes = new Set();
this.#seenStatus = new Set();
this.#downloadObjects = {};
}
/**
* Callback function that will combined data from different objects/files.
*
* @param {ProgressAndStatusCallbackParams} data - object containing the data
*/
aggregateCallback(data) {
if (this.watchedTypes.has(data.type)) {
this.#seenTypes.add(data.type);
this.#seenStatus.add(data.statusText);
if (data.statusText == ProgressStatusText.INITIATE) {
this.#remainingEvents += 1;
}
if (data.statusText == ProgressStatusText.SIZE_ESTIMATE) {
if (data.type != ProgressType.LOAD_FROM_CACHE) {
// We consider a downloaded object seen when we have the size estimate (object started downloading)
this.#totalObjectsSeen += 1;
this.#downloadObjects[data.id] = {
expected: data.total,
curTotal: 0,
};
}
}
const curDownload = this.#downloadObjects[data.id] || {};
if (data.statusText == ProgressStatusText.DONE) {
this.#remainingEvents -= 1;
if (data.type == ProgressType.LOAD_FROM_CACHE) {
// We consider a cached (not downloaded) object seen when loaded
this.#totalObjectsSeen += 1;
} else {
curDownload.curTotal = curDownload.expected; // Make totals match
}
}
if ("curTotal" in curDownload) {
curDownload.curTotal += data.currentLoaded;
if (curDownload.curTotal > curDownload.expected) {
// Make sure we don't go over 100%. Due to compression, sometimes the numbers don't add up as expected.
curDownload.curTotal = curDownload.expected;
}
}
if (this.progressCallback) {
let statusText = data.statusText;
if (this.#seenStatus.has(ProgressStatusText.IN_PROGRESS)) {
statusText = ProgressStatusText.IN_PROGRESS;
}
if (this.#remainingEvents == 0) {
statusText = ProgressStatusText.DONE;
}
const combinedLoadedManual = Object.keys(this.#downloadObjects).reduce(
(acc, key) => acc + this.#downloadObjects[key].curTotal,
0
);
const combinedTotalManual =
Object.keys(this.#downloadObjects).reduce(
(acc, key) => acc + this.#downloadObjects[key].expected,
0
) || 1;
data = { ...data, totalObjectsSeen: this.#totalObjectsSeen };
this.progressCallback(
new ProgressAndStatusCallbackParams({
type: data.type,
statusText,
id: data.id,
total: combinedTotalManual,
currentLoaded: data.currentLoaded,
totalLoaded: combinedLoadedManual,
progress: (combinedLoadedManual / combinedTotalManual) * 100,
ok: data.ok,
units: data.units,
metadata: data,
})
);
}
}
}
}
/**
* Fetches a URL and returns the response if the request is successful (status 2xx).
* Throws an error if the response status indicates failure.
*
* @async
* @function fetchUrl
* @param {string | URL} url - The URL to fetch.
* @param {RequestInit} [options] - Optional fetch options (method, headers, body, etc.).
* @returns {Promise<Response>} The fetch `Response` object.
* @throws {Error} If the response status is not in the 200–299 range.
*/
export async function fetchUrl(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(
`HTTP error! Status: ${response.status} ${response.statusText}`
);
}
return response;
}
/**
* Reads the body of a fetch `Response` object and writes it to a provided `WritableStream`,
* tracking progress and reporting it via a callback.
*
* @param {object} params - Parameters object.
* @param {Response} params.response - The fetch `Response` object containing the body to read.
* @param {WritableStream} params.writableStream - The destination stream where the response body
* will be written.
* @param {?function(ProgressAndStatusCallbackParams):void} params.progressCallback The function to call with progress updates.
* @param {?AbortSignal} params.abortSignal - AbortSignal to cancel the read.
*/
export async function readResponseToWriter({
response,
writableStream,
progressCallback,
abortSignal,
} = {}) {
// Attempts to retrieve the `Content-Length` header from the response to estimate total size.
const contentLength = response.headers.get("Content-Length");
if (!contentLength) {
console.warn(
"Unable to determine content-length from response headers. Progress percentage will be approximated."
);
}
let totalSize = parseInt(contentLength ?? "0");
let loadedSize = 0;
// Creates a `TransformStream` to monitor the transfer progress of each chunk.
const progressStream = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk); // Pass the chunk along to the writable stream
loadedSize += chunk.length;
totalSize = Math.max(totalSize, loadedSize);
// Reports progress updates via the `progressCallback` function if provided.
progressCallback?.(
new ProgressAndStatusCallbackParams({
progress: (loadedSize / totalSize) * 100,
totalLoaded: loadedSize,
currentLoaded: chunk.length,
total: totalSize,
units: "bytes",
})
);
},
});
// Pipes the response body through the progress stream into the writable stream and close the stream on completion/error.
await response.body
.pipeThrough(progressStream, { signal: abortSignal })
.pipeTo(writableStream, { signal: abortSignal });
}
// Create a "namespace" to make it easier to import multiple names.
export var Progress = Progress || {};
Progress.ProgressAndStatusCallbackParams = ProgressAndStatusCallbackParams;
Progress.ProgressStatusText = ProgressStatusText;
Progress.ProgressType = ProgressType;
Progress.readResponse = readResponse;
Progress.readResponseToWriter = readResponseToWriter;
Progress.fetchUrl = fetchUrl;
export async function getInferenceProcessInfo() {
// for now we only have a single inference process.
let info = await ChromeUtils.requestProcInfo();
for (const child of info.children) {
if (child.type === "inference") {
return {
pid: child.pid,
memory: child.memory,
cpuTime: child.cpuTime,
cpuCycleCount: child.cpuCycleCount,
};
}
}
return {};
}
const ALWAYS_ALLOWED_HUBS = [
"chrome://",
"resource://",
"http://localhost/",
"https://localhost/",
];
/**
* Enum for URL rejection types.
*
* Defines the type of rejection for a URL:
*
* - "DENIED" is for URLs explicitly disallowed by the deny list.
* - "NONE" is for URLs allowed by the allow list.
* - "DISALLOWED" is for URLs not matching any entry in either list.
*
* @readonly
* @enum {string}
*/
export const RejectionType = {
DENIED: "DENIED",
NONE: "NONE",
DISALLOWED: "DISALLOWED",
};
/**
* Class for checking URLs against allow and deny lists.
*/
export class URLChecker {
/**
* Creates an instance of URLChecker.
*
* @param {Array<{filter: 'ALLOW'|'DENY', urlPrefix: string}>} allowDenyList - Array of URL patterns with filters.
*/
constructor(allowDenyList = null) {
if (allowDenyList) {
this.allowList = allowDenyList
.filter(entry => entry.filter === "ALLOW")
.map(entry => entry.urlPrefix.toLowerCase());
this.denyList = allowDenyList
.filter(entry => entry.filter === "DENY")
.map(entry => entry.urlPrefix.toLowerCase());
} else {
this.allowList = [];
this.denyList = [];
}
// Always allowed
for (const url of ALWAYS_ALLOWED_HUBS) {
this.allowList.push(url);
}
}
/**
* Normalizes localhost URLs to ignore user info, port, and path details.
*
* @param {string} url - The URL to normalize.
* @returns {string} - Normalized URL.
*/
normalizeLocalhost(url) {
const parsedURL = URL.parse(url);
if (parsedURL?.hostname === "localhost") {
// Normalize to only scheme and localhost without port or user info
return `${parsedURL.protocol}//localhost/`;
}
return url;
}
/**
* Checks if a given URL is allowed based on allowList and denyList patterns.
*
* @param {string} url - The URL to check.
* @returns {{ allowed: boolean, rejectionType: string }} - Returns an object with:
* - `allowed`: true if the URL is allowed, otherwise false.
* - `rejectionType`:
* - "DENIED" if the URL matches an entry in the denyList,
* - "NONE" if the URL matches an entry in the allowList,
* - "DISALLOWED" if the URL does not match any entry in either list.
*/
allowedURL(url) {
const normalizedURL = this.normalizeLocalhost(url).toLowerCase();
// Check if the URL is denied by any entry in the denyList
if (this.denyList.some(prefix => normalizedURL.startsWith(prefix))) {
return { allowed: false, rejectionType: RejectionType.DENIED };
}
// Check if the URL is allowed by any entry in the allowList
if (this.allowList.some(prefix => normalizedURL.startsWith(prefix))) {
return { allowed: true, rejectionType: RejectionType.NONE };
}
// If no matches, return a default rejectionType
return { allowed: false, rejectionType: RejectionType.DISALLOWED };
}
}
/**
* Returns the optimal CPU concurrency for ML
*
* @returns {number} The number of threads we should be using
*/
export function getOptimalCPUConcurrency() {
let mlUtils = Cc["@mozilla.org/ml-utils;1"].createInstance(Ci.nsIMLUtils);
return mlUtils.getOptimalCPUConcurrency();
}
/**
* A class to check if some text belongs to a blocked list of n-grams.
*
*/
export class BlockListManager {
/**
* The set of blocked word n-grams.
*
* This set contains the n-grams (combinations of words) that are considered blocked.
* The n-grams are decoded from base64 to strings.
*
* @type {Set<string>}
*/
blockNgramSet = null;
/**
* Word segmenter for identifying word boundaries in the text.
*
* Used to segment the input text into words and ensure that n-grams are checked at word boundaries.
*
* @type {Intl.Segmenter}
*/
wordSegmenter = null;
/**
* The unique lengths of the blocked n-grams.
*
* This set stores the lengths of the blocked n-grams, allowing for efficient length-based checks.
* For example, if the blocked n-grams are "apple" (5 characters) and "orange" (6 characters),
* this set will store lengths {5, 6}.
*
* @type {Set<number>}
*/
blockNgramLengths = null;
/**
* Create an instance of the block list manager.
*
* @param {object} options - Configuration object.
* @param {string} options.language - A string with a BCP 47 language tag for the language of the blocked n-grams.
* Example: "en" for English, "fr" for French.
* See https://en.wikipedia.org/wiki/IETF_language_tag.
* @param {Array<string>} options.blockNgrams - Base64-encoded blocked n-grams.
*/
constructor({ blockNgrams, language = "en" } = {}) {
const blockNgramList = blockNgrams.map(base64Str =>
BlockListManager.decodeBase64(base64Str)
);
// TODO: Can be optimized by grouping the set by the word n-gram lenghts.
this.blockNgramSet = new Set(blockNgramList);
this.blockNgramLengths = new Set(blockNgramList.map(k => k.length)); // unique lengths
this.wordSegmenter = new Intl.Segmenter(language, { granularity: "word" });
}
/**
* Initialize the block list manager from the default list.
*
* @param {object} options - Configuration object.
* @param {string} options.language - A string with a BCP 47 language tag for the language of the blocked n-grams.
* Example: "en" for English, "fr" for French.
* See https://en.wikipedia.org/wiki/IETF_language_tag.
*
* @returns {BlockListManager} A new BlockListManager instance.
*/
static initializeFromDefault({ language = "en" } = {}) {
return new BlockListManager({
blockNgrams: lazy.BLOCK_WORDS_ENCODED[language],
language,
});
}
/**
* Initialize the block list manager from remote settings
*
* @param {object} options - Configuration object.
* @param {string} options.blockListName - Name of the block list within the remote setting collection.
* @param {string} options.language - A string with a BCP 47 language tag for the language of the blocked n-grams.
* Example: "en" for English, "fr" for French.
* See https://en.wikipedia.org/wiki/IETF_language_tag.
* @param {boolean} options.fallbackToDefault - Whether to fall back to the default block list if the remote settings retrieval fails.
* @param {number} options.majorVersion - The target version of the block list in remote settings.
* @param {number} options.collectionName - The remote settings collection holding the block list.
*
* @returns {Promise<BlockListManager>} A promise to a new BlockListManager instance.
*/
static async initializeFromRemoteSettings({
blockListName,
language = "en",
fallbackToDefault = true,
majorVersion = 1,
collectionName = RS_BLOCK_LIST_COLLECTION,
} = {}) {
try {
const record = await RemoteSettingsManager.getRemoteData({
collectionName,
filters: { name: blockListName, language },
majorVersion,
});
if (!record) {
throw new Error(
`No block list record found for ${JSON.stringify({ language, majorVersion, blockListName })}`
);
}
return new BlockListManager({
blockNgrams: record.blockList,
language,
});
} catch (error) {
if (fallbackToDefault) {
lazy.console.debug(
"Error when retrieving list from remote settings. Falling back to in-source list"
);
return BlockListManager.initializeFromDefault({ language });
}
throw error;
}
}
/**
* Decode a base64 encoded string to its original representation.
*
* @param {string} base64Str - The base64 encoded string to decode.
* @returns {string} The decoded string.
*/
static decodeBase64(base64Str) {
const binary = atob(base64Str); // binary string
// Convert binary string to byte array
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
// Decode bytes to Unicode string
return new TextDecoder().decode(bytes);
}
/**
* Encode a string to base64.
*
* @param {string} str - The string to encode.
* @returns {string} The base64 encoded string.
*/
static encodeBase64(str) {
// Convert Unicode string to bytes
const bytes = new TextEncoder().encode(str); // Uint8Array
// Convert bytes to binary string
const binary = String.fromCharCode(...bytes);
// Encode binary string to base64
return btoa(binary);
}
/**
* Check if blocked n-grams are present at word boundaries in the given text.
*
* This method checks the text at word boundaries (using the word segmenter) for any n-grams that are blocked.
*
* @param {object} options - Configuration object.
* @param {string} options.text - The text to check for blocked n-grams.
* @returns {boolean} True if the text contains a blocked word n-gram, false otherwise.
*
* @example
* const result = blockListManager.matchAtWordBoundary({ text: "this is spam text" });
* console.log(result); // true if 'spam' is a blocked n-gram.
* const result2 = blockListManager.matchAtWordBoundary({ text: "this isspam text" });
* console.log(result2); // false even if spam is a blocked n-gram.
*/
matchAtWordBoundary({ text }) {
const isTextOffsetAtEndOfWordBoundary = new Array(text.length).fill(false);
// Keep hold of the index of the first character of each word in the text
const startWordIndices = Array.from(
this.wordSegmenter.segment(text),
segment => {
if (segment.index > 0) {
// segment.index returns start of word. Subtracting one for end of word.
isTextOffsetAtEndOfWordBoundary[segment.index - 1] = true;
}
return segment.index;
}
);
// End of text always at word boundary
isTextOffsetAtEndOfWordBoundary[text.length - 1] = true;
for (const startTextOffset of startWordIndices) {
// Check if there is a word starting at offset startTextOffset and matching a blocked n-gram words of given length
for (const blockLength of this.blockNgramLengths) {
const endTextOffset = startTextOffset + blockLength;
if (
// Skip checking when the pattern to check does not end at word boundary.
isTextOffsetAtEndOfWordBoundary[endTextOffset - 1] &&
// check if we have this word in the block list
this.blockNgramSet.has(text.slice(startTextOffset, endTextOffset))
) {
return true;
}
}
}
return false;
}
/**
* Check if blocked n-grams are present anywhere in the text.
*
* This method checks the entire text (not limited to word boundaries) for any n-grams that are blocked.
*
* @param {object} options - Configuration object.
* @param {string} options.text - The text to check for blocked n-grams.
* @returns {boolean} True if the text contains a blocked word n-gram, false otherwise.
*
* @example
* const result = blockListManager.matchAnywhere({ text: "this is spam text" });
* console.log(result); // true if 'spam' is a blocked n-gram.
* const result2 = blockListManager.matchAnywhere({ text: "this isspam text" });
* console.log(result2); // true if 'spam' is a blocked n-gram.
* const result3 = blockListManager.matchAnywhere({ text: "this is s_p_a_m text" });
* console.log(result3); // false even if 'spam' is a blocked n-gram.
*/
matchAnywhere({ text }) {
for (
let startTextOffset = 0;
startTextOffset < text.length;
startTextOffset++
) {
for (const blockLength of this.blockNgramLengths) {
if (
this.blockNgramSet.has(
text.slice(startTextOffset, startTextOffset + blockLength)
)
) {
return true;
}
}
}
return false;
}
}
/**
* A class to retrieve data from remote setting
*
*/
export class RemoteSettingsManager {
/**
* The cached remote settings clients that downloads the data.
*
* @type {Record<string, RemoteSettingsClient>}
*/
static #remoteClients = {};
/**
* Remote settings isn't available in tests, so provide mocked clients.
*
* @param {Record<string, RemoteSettingsClient>} remoteClients
*/
static mockRemoteSettings(remoteClients) {
lazy.console.log("Mocking remote settings in RemoteSettingsManager.");
RemoteSettingsManager.#remoteClients = remoteClients;
}
/**
* Remove anything that could have been mocked.
*/
static removeMocks() {
lazy.console.log("Removing mocked remote client in RemoteSettingsManager.");
RemoteSettingsManager.#remoteClients = {};
}
/**
* Lazily initialize the remote settings client responsible for downloading the data.
*
* @param {string} collectionName - The name of the collection to use.
* @returns {RemoteSettingsClient}
*/
static getRemoteClient(collectionName) {
if (RemoteSettingsManager.#remoteClients[collectionName]) {
return RemoteSettingsManager.#remoteClients[collectionName];
}
/** @type {RemoteSettingsClient} */
const client = lazy.RemoteSettings(collectionName, {
bucketName: "main",
});
RemoteSettingsManager.#remoteClients[collectionName] = client;
client.on("sync", async ({ data: { created, updated, deleted } }) => {
lazy.console.debug(`"sync" event for ${collectionName}`, {
created,
updated,
deleted,
});
// Remove all the deleted records.
for (const record of deleted) {
await client.attachments.deleteDownloaded(record);
}
// Remove any updated records, and download the new ones.
for (const { old: oldRecord } of updated) {
await client.attachments.deleteDownloaded(oldRecord);
}
// Do nothing for the created records.
});
return client;
}
/**
* Gets data from remote settings.
*
* @param {object} options - Configuration object
* @param {string} options.collectionName - The name of the remote settings collection.
* @param {object} options.filters - The filters to use where key should match the schema in remote settings.
* @param {number|null} options.majorVersion - The target version or null if no version is supported.
* @param {Function} [options.lookupKey=(record => record.name)]
* The function to use to extract a lookup key from each record when versionning is supported..
* This function should take a record as input and return a string that represents the lookup key for the record.
* @returns {Promise<object|null>}
*/
static async getRemoteData({
collectionName,
filters,
majorVersion,
lookupKey = record => record.name,
} = {}) {
const client = RemoteSettingsManager.getRemoteClient(collectionName);
let records = [];
if (majorVersion) {
records = await lazy.TranslationsParent.getMaxSupportedVersionRecords(
client,
{
filters,
minSupportedMajorVersion: majorVersion,
maxSupportedMajorVersion: majorVersion,
lookupKey,
}
);
} else {
records = await client.get({ filters });
}
// Handle case where multiple records exist
if (records.length > 1) {
throw new Error(
`Found more than one record in '${collectionName}' for filters ${JSON.stringify(filters)}. Double-check your filters.`
);
}
// If still no records, return null
if (records.length === 0) {
return null;
}
return records[0];
}
}
const ADDON_PREFIX = "ML-ENGINE-";
/**
* Check if an engine id is for an addon
*
* @param {string} engineId - The engine id to check
* @returns {boolean} True if the engine id is for an addon
*/
export function isAddonEngineId(engineId) {
return engineId.startsWith(ADDON_PREFIX);
}
/**
* Converts an addon id to an engine id
*
* @param {string} addonId - The addon id to convert
* @returns {string} The engine id
*/
export function addonIdToEngineId(addonId) {
return `${ADDON_PREFIX}${addonId}`;
}
/**
* Converts an engine Id into an addon id
*
* @param {string} engineId - The engine id to convert
* @returns {string|null} The addon id. null if the engine id is invalid
*/
export function engineIdToAddonId(engineId) {
if (!engineId.startsWith(ADDON_PREFIX)) {
return null;
}
return engineId.substring(ADDON_PREFIX.length);
}
/**
* Converts a feature engine id to a fluent id
*
* @param {string} engineId
* @returns {string|null}
*/
export function featureEngineIdToFluentId(engineId) {
for (const config of Object.values(lazy.FEATURES)) {
if (config.engineId === engineId) {
return config.fluentId;
}
}
return null;
}
/**
* Generates a random uuid to use where Services.uuid is not available,
* for instance pipelines
*
* @returns {string}
*/
export function generateUUID() {
lazy.console.debug("generating uuid");
return crypto.randomUUID();
}
/**
* Checks if we are in private browsing mode
*
* @returns {boolean} True if we are in private browsing mode
*/
export function isPrivateBrowsing() {
const win = Services.wm.getMostRecentBrowserWindow() ?? null;
return lazy.PrivateBrowsingUtils.isWindowPrivate(win);
}
/**
* Helpers used to collect telemetry related to the mlmodel management UI
* (used by about:addons)
*/
function baseRecordData(modelAddonWrapper) {
const { usedByAddonIds, usedByFirefoxFeatures, model, version } =
modelAddonWrapper;
return {
extension_ids: usedByAddonIds.join(","),
feature_ids: usedByFirefoxFeatures.join(","),
model,
version,
};
}
export function recordRemoveConfirmationTelemetry(modelAddonWrapper, confirm) {
Glean.modelManagement.removeConfirmation.record({
...baseRecordData(modelAddonWrapper),
action: confirm ? "remove" : "cancel",
});
}
export function recordListItemManageTelemetry(modelAddonWrapper) {
Glean.modelManagement.listItemManage.record({
...baseRecordData(modelAddonWrapper),
});
}
function convertDateToHours(date) {
const now = Date.now();
return Math.floor((now - date.getTime()) / 1000 / 60 / 60); // hours
}
export function recordRemoveInitiatedTelemetry(modelAddonWrapper, source) {
const { lastUsed, updateDate, totalSize } = modelAddonWrapper;
Glean.modelManagement.removeInitiated.record({
...baseRecordData(modelAddonWrapper),
source,
size: totalSize,
last_used: convertDateToHours(lastUsed),
last_install: convertDateToHours(updateDate),
});
}
export function recordModelCardLinkTelemetry(modelAddonWrapper) {
Glean.modelManagement.modelCardLink.record({
...baseRecordData(modelAddonWrapper),
});
}
export function recordListViewTelemetry(qty) {
Glean.modelManagement.listView.record({
models: qty,
});
}
export function recordDetailsViewTelemetry(modelAddonWrapper) {
Glean.modelManagement.detailsView.record({
...baseRecordData(modelAddonWrapper),
});
}
/**
* Converts a binary string (where each character represents a byte) into a hexadecimal string.
*
* @param {string} binaryStr - The binary string to convert.
* @returns {string} The resulting hexadecimal string.
*/
export function binaryToHex(binaryStr) {
return Array.from(binaryStr)
.map(c => c.charCodeAt(0).toString(16).padStart(2, "0"))
.join("");
}
/**
* Computes a cryptographic hash of a Blob using the specified algorithm and output format.
*
* @param {Blob} blob - The Blob to hash.
* @param {("md5"|"sha1"|"sha256"|"sha384"|"sha512")} [algorithm="sha256"] - The hashing algorithm to use.
* @param {("hex"|"binary"|"base64")} [outputFormat="hex"] - The output format of the hash.
* @returns {Promise<string>} The computed hash as a string in the specified format.
*/
export async function computeHash(
blob,
algorithm = "sha256",
outputFormat = "hex"
) {
let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
Ci.nsICryptoHash
);
hasher.initWithString(algorithm);
const hashingTransform = new TransformStream({
transform(chunk, controller) {
hasher.update(chunk, chunk.length);
controller.enqueue(chunk); // pass through
},
});
const sink = new WritableStream({
write() {
/* discard */
},
});
await blob.stream().pipeThrough(hashingTransform).pipeTo(sink);
const base64 = outputFormat === "base64";
let hash = hasher.finish(/* base64 */ base64);
if (outputFormat === "hex") {
hash = binaryToHex(hash);
}
return hash;
}
// Utils operations
export var MLUtils = MLUtils || {};
MLUtils.fetchUrl = fetchUrl;
/**
* Safely stringify any value for logging/debugging.
*
* This function guarantees a string is returned and will never throw,
* even for values that JSON.stringify cannot handle (BigInt, Symbols,
* circular references, proxies with throwing getters, etc).
*
* It tries JSON.stringify first with a safe replacer, then falls back to
* a bounded inspection that handles depth, length, and property limits.
*
* @param {*} value - The value to stringify for logging (any type).
* @param {object} [options] - Optional limits to control output.
* @param {number} [options.maxDepth=3] - Maximum recursion depth for nested objects.
* @param {number} [options.maxKeysPerLevel=50] - Maximum number of keys per object or items per array to include.
* @param {number} [options.maxOutputLength=20000] - Maximum number of characters in the final output string.
*
* @returns {string} A safe string representation of the input, never throwing.
*
* @example
* lazy.console.debug(`Chunk received ${stringifyForLog(chunk.metadata)}`);
*
* @example
* const txt = stringifyForLog({ a: 1n, b: new Map([["x", 42]]) });
* // → '{"a":"1","b":{"x":42}}'
*/
export function stringifyForLog(
value,
{ maxDepth = 3, maxKeysPerLevel = 50, maxOutputLength = 20_000 } = {}
) {
// 1) Fast path: JSON with a safe replacer
try {
const seen = new WeakSet();
// Small type-aware helper that keeps output concise and stable
const toStringish = v => {
if (typeof v === "bigint" || typeof v === "symbol") {
return String(v);
}
if (v instanceof Date) {
return v.toISOString();
} // stable
if (v instanceof RegExp) {
return v.toString();
} // /re/flags
return undefined; // signal "no change"
};
const txt = JSON.stringify(value, (_, v) => {
// cheap string-ification for a few tricky primitives/objects
const s = toStringish(v);
if (s !== undefined) {
return s;
}
if (typeof v === "function") {
// Avoid dumping source
return `[Function ${v.name || "anonymous"}]`;
}
if (v instanceof Error) {
// Keep the useful fields
return { name: v.name, message: v.message, stack: v.stack };
}
if (v instanceof Map) {
return Object.fromEntries([...v.entries()].slice(0, maxKeysPerLevel));
}
if (v instanceof Set) {
return [...v.values()].slice(0, maxKeysPerLevel);
}
if (ArrayBuffer.isView(v)) {
return `${v.constructor.name}(${v.byteLength} bytes)`;
}
if (v instanceof ArrayBuffer) {
return `ArrayBuffer(${v.byteLength} bytes)`;
}
if (v && typeof v === "object") {
if (seen.has(v)) {
return "[Circular]";
}
seen.add(v);
}
return v;
});
if (typeof txt === "string") {
return txt.length > maxOutputLength
? txt.slice(0, maxOutputLength) + "…[truncated]"
: txt;
}
} catch (_) {
// fall through to slow path
}
// 2) Slow path: guarded, shallow-ish serializer
//
// Why we need this:
// - JSON.stringify can still fail or be unhelpful even with a replacer,
// for example top-level BigInt, exotic proxies with throwing getters,
// or values that JSON reduces to "{}" while a human-readable preview
// would be more useful.
// - We also want bounded, readable output when JSON would be massive.
//
// How it works and why it is safe:
// - Never throws: every property access is try/catch protected so getters
// that throw or proxy traps cannot break logging.
// - Bounded traversal: depth is capped by maxDepth and the number of
// keys or items per level is capped by maxKeysPerLevel.
// - Cycle safe: a WeakSet tracks seen objects and prints "[Circular]".
// - Type-aware summaries: Dates use ISO, RegExp uses "/re/flags",
// Errors are "Name: message", TypedArrays and ArrayBuffer show sizes,
// Arrays show a preview with a possible "…" tail.
// - Constructor tag: for non-plain objects we prefix with the class name
// to keep helpful context without full expansion.
// - Final guard: the final string is truncated to maxOutputLength.
const seen2 = new WeakSet();
function safeDescribe(x, depth = 0) {
if (x === null) {
return "null";
}
const t = typeof x;
if (t === "bigint" || t === "symbol") {
return String(x);
}
if (t === "function") {
return `[Function ${x.name || "anonymous"}]`;
}
if (t !== "object") {
// Handles number, string, boolean, undefined
try {
return JSON.stringify(x);
} catch {
// Fallback for weird host objects
return String(x);
}
}
if (seen2.has(x)) {
return "[Circular]";
}
seen2.add(x);
if (x instanceof Date) {
return `Date(${isNaN(x.getTime()) ? "Invalid" : x.toISOString()})`;
}
if (x instanceof RegExp) {
return x.toString();
}
if (x instanceof Error) {
return `${x.name}: ${x.message}`;
}
if (Array.isArray(x)) {
if (depth >= maxDepth) {
return `[Array(${x.length})]`;
}
const items = [];
for (let i = 0; i < Math.min(x.length, maxKeysPerLevel); i++) {
try {
items.push(safeDescribe(x[i], depth + 1));
} catch (e) {
items.push(`[Thrown: ${(e && e.message) || e}]`);
}
}
if (x.length > maxKeysPerLevel) {
items.push("…");
}
return `[${items.join(", ")}]`;
}
if (ArrayBuffer.isView(x)) {
return `${x.constructor.name}(${x.byteLength} bytes)`;
}
if (x instanceof ArrayBuffer) {
return `ArrayBuffer(${x.byteLength} bytes)`;
}
if (x instanceof Map) {
return `Map(${x.size})`;
}
if (x instanceof Set) {
return `Set(${x.size})`;
}
if (depth >= maxDepth) {
return `[Object ${(x && x.constructor && x.constructor.name) || "Object"}]`;
}
const out = [];
let names = [];
try {
names = [
...new Set([
...Object.keys(x),
...Object.getOwnPropertyNames(x).filter(k => !k.startsWith("#")),
]),
];
} catch (e) {
return `[Uninspectable: ${(e && e.message) || e}]`;
}
for (const key of names.slice(0, maxKeysPerLevel)) {
try {
const val = x[key];
out.push(`${JSON.stringify(key)}: ${safeDescribe(val, depth + 1)}`);
} catch (e) {
out.push(`${JSON.stringify(key)}: [Thrown: ${(e && e.message) || e}]`);
}
}
if (names.length > maxKeysPerLevel) {
out.push(`"…": "more properties omitted"`);
}
const tag =
x &&
x.constructor &&
x.constructor.name &&
x.constructor.name !== "Object"
? x.constructor.name
: "";
return tag ? `${tag} { ${out.join(", ")} }` : `{ ${out.join(", ")} }`;
}
let s = safeDescribe(value);
if (typeof s !== "string") {
try {
s = JSON.stringify(s);
} catch {
s = String(s);
}
}
if (s.length > maxOutputLength) {
s = s.slice(0, maxOutputLength) + "…[truncated]";
}
return s;
}
/**
* Reads into an ArrayBuffer keeping track of the offsets.
*/
class ByteReader {
/**
* @param {ArrayBuffer} buffer
*/
constructor(buffer) {
this.offset = 0;
this.buffer = buffer;
this.view = new DataView(buffer);
}
/**
* @returns {number}
*/
uint8() {
return this.view.getUint8(this.offset++);
}
/**
* @param {"little" | "big"} endianess
*/
uint16(endianess) {
const value = this.view.getUint16(this.offset, endianess == "little");
this.offset += 2;
return value;
}
/**
* @param {number} length
* @returns {string}
*/
latin1(length) {
const bytes = new Uint8Array(this.buffer, this.offset, length);
this.offset += length;
const decoder = new TextDecoder("latin1");
return decoder.decode(bytes);
}
/**
* Return the remaining data.
*/
sliceRemaining() {
return this.buffer.slice(this.offset);
}
}
/**
* Parse an ArrayBuffer of a .npy file into a typed array and shape.
*
* https://numpy.org/doc/stable/reference/generated/numpy.lib.format.html
*
* @param {ArrayBuffer} buffer The ArrayBuffer containing the .npy data.
* @returns {{data: TypedArray, shape: number[], dtype: string}}
*/
export function parseNpy(buffer) {
const reader = new ByteReader(buffer);
if (reader.uint8() != 0x93 || reader.latin1(5) != "NUMPY") {
throw new Error("Not a valid .npy file");
}
const majorVersion = reader.uint8();
reader.uint8(); // minorVersion
if (majorVersion != 1) {
throw new Error("Only major version 1 is currently supported.");
}
const headerLength = reader.uint16("little");
let headerText = reader.latin1(headerLength).trim();
// Header is a Python dict string. Do some text manipulation to make it JSON parseable.
//
// "{'descr': '<f8', 'fortran_order': False, 'shape': (3, 4), }"
// "{'descr': '|u1', 'fortran_order': False, 'shape': (63091, 128), }"
headerText = headerText
.replace(/'/g, '"') // single to double quotes
.replace("False", "false")
.replace("True", "true")
.replace(/,\s*}/, "}") // trailing commas
.replace(/,\s*\)/, ")"); // trailing commas in tuple
const header = JSON.parse(
headerText.replace(/\((.*?)\)/, (m, inner) => {
// convert shape tuple into JSON array
return `[${inner.trim().replace(/, /g, ",")}]`;
})
);
if (header.fortran_order) {
throw new Error("Unable to parse an array using fortran_order");
}
const fullType = header.descr; // e.g. '<f8'
const littleEndian = fullType[0] === "<" || fullType[0] === "|";
const dtype = fullType.slice(1);
const shape = header.shape;
const dataBuffer = reader.sliceRemaining();
let typedArray;
switch (dtype) {
case "f8": // float64
typedArray = new Float64Array(dataBuffer);
break;
case "f4": // float32
typedArray = new Float32Array(dataBuffer);
break;
case "f2": // float16
typedArray = new Float16Array(dataBuffer);
break;
case "i4": // int32
typedArray = new Int32Array(dataBuffer);
break;
case "i2": // int16
typedArray = new Int16Array(dataBuffer);
break;
case "i1": // int8
typedArray = new Int8Array(dataBuffer);
break;
case "u4": // uint32
typedArray = new Uint32Array(dataBuffer);
break;
case "u2": // uint16
typedArray = new Uint16Array(dataBuffer);
break;
case "u1": // uint8
typedArray = new Uint8Array(dataBuffer);
break;
default:
throw new Error(`Unsupported dtype: ${fullType}`);
}
let expectedLength = 1;
for (const size of shape) {
expectedLength *= size;
}
if (typedArray.length != expectedLength) {
throw new Error(
`The data length (${typedArray.length}) did not match the expected dimensions (${expectedLength}) for shape ${JSON.stringify(shape)}`
);
}
// If endianness doesn't match, swap the bytes.
if (!littleEndian && typedArray.BYTES_PER_ELEMENT > 1) {
const u8 = new Uint8Array(typedArray.buffer);
for (let i = 0; i < u8.length; i += typedArray.BYTES_PER_ELEMENT) {
u8.subarray(i, i + typedArray.BYTES_PER_ELEMENT).reverse();
}
}
return { data: typedArray, shape, dtype };
}
/**
* Resolves with all values if all promises succeed, otherwise rejects with all errors.
*
* @param {Promise[]} promises Promises to wait for.
* @returns {Promise<unknown[]>} Fulfilled values in input order.
* @throws {AggregateError|Error} If one or more promises are rejected.
*/
export async function allSettledOrReject(promises) {
const results = await Promise.allSettled(promises);
const errors = results
.filter(r => r.status === "rejected")
.map(r => r.reason);
if (errors.length === 1) {
throw errors[0];
}
if (errors.length) {
throw new AggregateError(errors, errors.map(e => e.message).join("; "));
}
return results.map(r => r.value);
}
/**
* Utilities for uninstalling ML features or removing all ML-related data.
*
* Provides static methods to perform targeted or full uninstalls, with
* optional reuse of a shared ModelHub instance.
*/
export class MLUninstallService {
/**
* Lazily created default ModelHub used when no hub is provided.
*
* @type {ModelHub|null}
* @private
*/
static #defaultHub = null;
/**
* Get or create the default ModelHub instance.
*
* @returns {ModelHub}
* @private
*/
static #getDefaultHub() {
return (this.#defaultHub ??= new lazy.ModelHub());
}
/**
* Uninstall a feature by removing all engine instances it uses and deleting
* all associated files for those engines.
*
* The caller passes all engine IDs that belong to the feature being removed.
*
* @param {object} params
* @param {string[]} params.engineIds Engine IDs used by the feature to uninstall.
* @param {string} [params.actor="other"] Identifier indicating who/what initiated the uninstall.
* @param {ModelHub} [params.hub] ModelHub instance to use. Use the default if not provided.
* @returns {Promise<void>}
* @throws {Error} If removing an engine instance or deleting its associated files fails.
*/
static async uninstall({ engineIds, actor = "other", hub }) {
const modelHub = hub ?? this.#getDefaultHub();
const promises = [];
for (const engineId of engineIds) {
promises.push(
lazy.MLEngine.removeInstance(engineId).then(() =>
modelHub.deleteFilesByEngine({ engineId, deletedBy: actor })
)
);
}
await allSettledOrReject(promises);
}
/**
* Completely remove the ML engine and all associated data.
*
* This operation destroys all ML-related engine instances and
* permanently deletes all cached model data.
*
* @param {object} params
* @param {ModelHub} [params.hub] ModelHub instance to use. Use the default if not provided.
*
* @throws {Error} If any step of the uninstall process fails.
*/
static async uninstallAll({ hub } = {}) {
const modelHub = hub ?? this.#getDefaultHub();
await lazy.EngineProcess.destroyMLEngine();
await lazy.EngineProcess.destroyTranslationsEngine();
await modelHub.purgeDatabase();
}
}