@molecule/api-ai-translation-bergamot
Provider bond · ai-translation · API (Node) · v1.1.1 · Apache-2.0
Self-hosted translation provider for molecule.dev — Mozilla's Firefox Translations (Bergamot) models on the CPU, in-process, no API key
npm install @molecule/api-ai-translation-bergamotnpm · Source on GitHub · Implements @molecule/api-ai-translation
How it works
@molecule/api-ai-translation-bergamot is a provider bond on the API (Node) side: it implements the ai-translation core interface (@molecule/api-ai-translation) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider, requireProvider } from '@molecule/api-ai-translation'
import { createProvider } from '@molecule/api-ai-translation-bergamot'
// Models download into cacheDir on first use (20–50 MB per language pair).
setProvider(createProvider({ cacheDir: '/var/cache/bergamot', workers: 4 }))
const { translations } = await requireProvider().translate({
text: ['{{count}} items in your cart', 'Checkout'],
sourceLang: 'en',
targetLang: 'de',
protect: ['{{count}}'],
})Works with: @molecule/api-ai-translation
Reference
Self-hosted translation provider for molecule.dev, built on Mozilla's Firefox Translations (Bergamot) models.
Implements the @molecule/api-ai-translation contract by running the same
small translation models Firefox uses, in-process, on the CPU, through the
Bergamot WASM runtime. No API key, no per-character cost, no Python, no GPU;
after the first download no text leaves the host.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider, requireProvider } from '@molecule/api-ai-translation'
import { createProvider } from '@molecule/api-ai-translation-bergamot'
// Models download into cacheDir on first use (20–50 MB per language pair).
setProvider(createProvider({ cacheDir: '/var/cache/bergamot', workers: 4 }))
const { translations } = await requireProvider().translate({
text: ['{{count}} items in your cart', 'Checkout'],
sourceLang: 'en',
targetLang: 'de',
protect: ['{{count}}'],
})
Type
provider
Installation
npm install @molecule/api-ai-translation-bergamot @molecule/api-ai-translation
API
Interfaces
WorkerEngineOptions
Options for createWorkerEngine.
interface WorkerEngineOptions {
/** Resolves the runtime files, downloading them on first use. */
runtime: () => Promise<{ wasmPath: string; gluePath: string }>
/** Worker threads per route. */
workers: number
/** Routes (language pairs) kept loaded at once. */
maxLoadedRoutes: number
}
ProtectedSpan
One protected occurrence and the spacing around it in the source.
interface ProtectedSpan {
/** The protected substring. */
text: string
/** Whether the source had whitespace directly before it. */
spaceBefore: boolean
/** Whether the source had whitespace directly after it. */
spaceAfter: boolean
}
ModelRecord
One record of Firefox's model list (the fields this bond reads).
interface ModelRecord {
/** File name, e.g. `model.ende.intgemm.alphas.bin`. */
name: string
/** Source language. */
fromLang: string
/** Target language. */
toLang: string
/** Model version, e.g. `2.1` (pre-releases look like `2.1a1`). */
version: string
/** `model`, `lex`, `vocab`, `srcvocab`, `trgvocab` or `qualityModel`. */
fileType: string
/** Firefox targeting rule, e.g. only Nightly or only Android (see `matchesServer`). */
filter_expression?: string
/** Where the file lives and its hash. */
attachment: {
/** SHA-256 of the file. */
hash: string
/** Size in bytes. */
size: number
/** Path relative to the attachments URL. */
location: string
}
}
ModelSet
The files of one language pair at one version.
interface ModelSet {
/** Source language. */
from: string
/** Target language. */
to: string
/** Version shared by every file. */
version: string
/** The record for each file role. */
files: Partial<Record<keyof BergamotModelFiles, ModelRecord>>
}
ModelStoreOptions
Options for ModelStore.
interface ModelStoreOptions {
/** Cache directory. */
cacheDir: string
/** Model list URL. */
recordsUrl: string
/** Model file base URL. */
attachmentsUrl: string
/** How long a cached model list is trusted. */
recordsMaxAgeMs: number
/** `fetch` implementation. */
fetch: typeof fetch
}
BergamotConfig
Configuration for the Bergamot translation provider.
interface BergamotConfig {
/**
* Directory the models and the translation runtime are downloaded into on
* first use (about 20–50 MB per language pair, plus 5 MB of runtime). Defaults
* to `BERGAMOT_CACHE_DIR`, then `~/.cache/molecule/bergamot`. Keep it on a
* persistent volume so a restart does not download everything again.
*/
cacheDir?: string
/**
* Worker threads per language pair, translating chunks of one batch in parallel.
* Each holds its own copy of the runtime and the pair's model(s) — roughly
* 100–250 MB of memory per worker. Defaults to `BERGAMOT_WORKERS`, then 1.
*/
workers?: number
/**
* Language pairs (routes) kept loaded at once; the least recently used one is shut
* down beyond this. Defaults to 4.
*/
maxLoadedPairs?: number
/**
* The list of published models (a Mozilla Remote Settings collection). Defaults to
* Firefox's own; point it at a mirror to serve the models from your own host.
*/
recordsUrl?: string
/** Base URL the model files are downloaded from. Defaults to Firefox's model CDN. */
attachmentsUrl?: string
/** How long the downloaded model list is trusted before it is fetched again. Defaults to 24 hours. */
recordsMaxAgeMs?: number
/**
* A local copy of `bergamot-translator.wasm` (release v0.6.0). When set, nothing is
* downloaded for the runtime — use this, with `gluePath`, on hosts without internet.
*/
wasmPath?: string
/** A local copy of the matching `bergamot-translator.js` loader (v0.6.0). */
gluePath?: string
/** Replaces the worker-thread engine — for tests, or to run the engine elsewhere. */
engine?: BergamotEngine
/** Replaces `fetch` for downloads — for tests or a proxy-aware client. */
fetch?: typeof fetch
}
BergamotModelFiles
Paths of one model's files on disk, by the role Bergamot gives them.
interface BergamotModelFiles {
/** The translation model (int8 weights). */
model: string
/** The lexical shortlist that limits the decoder's output vocabulary. */
lex: string
/** A vocabulary shared by both languages — or the two below. */
vocab?: string
/** Source-language vocabulary, when the model has separate ones. */
srcvocab?: string
/** Target-language vocabulary, when the model has separate ones. */
trgvocab?: string
}
BergamotModelSpec
One translation model the engine should load.
interface BergamotModelSpec {
/** Stable identity used for the engine's model cache, e.g. `en-de@2.1`. */
key: string
/** Source language (Bergamot code). */
from: string
/** Target language (Bergamot code). */
to: string
/** The model's files on disk. */
files: BergamotModelFiles
}
BergamotRequest
One batch sent to the engine.
interface BergamotRequest {
/** One model to translate directly, or two to pivot through English. */
models: BergamotModelSpec[]
/** Texts to translate. */
texts: string[]
/** Whether the texts are HTML (tags are carried over to the translation). */
html: boolean
}
BergamotEngine
What runs the Bergamot runtime. The default is a pool of worker threads.
interface BergamotEngine {
/**
* Translates one batch.
*
* @param request - Models, texts and mode.
* @returns One translation per text, in order.
*/
translate(request: BergamotRequest): Promise<string[]>
/** Stops the engine and frees its memory. */
close(): Promise<void>
}
Classes
ModelStore
Model list + download cache, shared by every request of one provider.
class ModelStore {
private recordsPromise: Promise<ModelRecord[]> | null = null
private recordsLoadedAt = 0
private readonly inFlight = new Map<string, Promise<string>>()
/**
* Creates a model store.
*
* @param options - Where to cache and download from.
*/
constructor(private readonly options: ModelStoreOptions) {}
/**
* Firefox's model list — from memory, the on-disk copy, or the network.
*
* A fresh copy is fetched when the cached one is older than `recordsMaxAgeMs`;
* if that fetch fails, the older copy on disk is used rather than failing
* translations that already have their models.
*
* @returns The records.
* @throws {Error} When there is no cached list and the fetch fails.
*/
async records(): Promise<ModelRecord[]> {
const fresh = Date.now() - this.recordsLoadedAt < this.options.recordsMaxAgeMs
if (this.recordsPromise && fresh) return this.recordsPromise
this.recordsPromise = this.loadRecords().catch((error: unknown) => {
this.recordsPromise = null
throw error
})
return this.recordsPromise
}
/**
* Downloads (if needed) the files of a model set.
*
* @param set - The set from `selectModelSet`.
* @returns The engine's view of it.
*/
async ensureModel(set: ModelSet): Promise<BergamotModelSpec> {
const files: Partial<BergamotModelFiles> = {}
await Promise.all(
Object.entries(set.files).map(async ([role, record]) => {
const dest = join(
this.options.cacheDir,
'models',
`${set.from}-${set.to}`,
set.version,
`${record.attachment.hash.slice(0, 16)}-${record.name}`,
)
files[role as keyof BergamotModelFiles] = await this.download(
new URL(record.attachment.location, this.options.attachmentsUrl).toString(),
record.attachment.hash,
dest,
)
}),
)
return {
key: `${set.from}-${set.to}@${set.version}`,
from: set.from,
to: set.to,
files: files as BergamotModelFiles,
}
}
/**
* Downloads (if needed) the pinned runtime.
*
* @returns Paths of the WASM binary and its loader.
*/
async ensureRuntime(): Promise<{ wasmPath: string; gluePath: string }> {
const dir = join(this.options.cacheDir, 'runtime')
const [wasmPath, gluePath] = await Promise.all([
this.download(
new URL(BERGAMOT_RUNTIME.wasm.path, this.options.attachmentsUrl).toString(),
BERGAMOT_RUNTIME.wasm.sha256,
join(dir, BERGAMOT_RUNTIME.wasm.filename),
),
this.download(
BERGAMOT_RUNTIME.glue.url,
BERGAMOT_RUNTIME.glue.sha256,
join(dir, BERGAMOT_RUNTIME.glue.filename),
),
])
return { wasmPath, gluePath }
}
/**
* One download per destination at a time.
*
* @param url - Source URL.
* @param sha256 - Expected hash.
* @param dest - Destination path.
* @returns `dest`.
*/
private download(url: string, sha256: string, dest: string): Promise<string> {
const pending = this.inFlight.get(dest)
if (pending) return pending
const promise = downloadVerified(this.options.fetch, url, sha256, dest).finally(() =>
this.inFlight.delete(dest),
)
this.inFlight.set(dest, promise)
return promise
}
/**
* Reads the on-disk list if it is fresh, otherwise fetches it.
*
* @returns The records.
*/
private async loadRecords(): Promise<ModelRecord[]> {
const file = join(this.options.cacheDir, 'records.json')
const cached = await readFile(file, 'utf8')
.then((text) => JSON.parse(text) as { fetchedAt: number; data: ModelRecord[] })
.catch((_error: unknown) => null) // no (or unreadable) cached list → fetch below
if (cached && Date.now() - cached.fetchedAt < this.options.recordsMaxAgeMs) {
this.recordsLoadedAt = cached.fetchedAt
return cached.data
}
try {
const response = await this.options.fetch(this.options.recordsUrl)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const { data } = (await response.json()) as { data: ModelRecord[] }
await mkdir(this.options.cacheDir, { recursive: true })
await writeFile(file, JSON.stringify({ fetchedAt: Date.now(), data }))
this.recordsLoadedAt = Date.now()
return data
} catch (error) {
if (cached) {
// Stale but usable: a model list outage must not stop pairs that are already cached.
this.recordsLoadedAt = Date.now()
return cached.data
}
throw new Error(`Bergamot could not load the model list from ${this.options.recordsUrl}`, {
cause: error,
})
}
}
}
Functions
createWorkerEngine(options)
Creates a worker-thread Bergamot engine.
function createWorkerEngine(options: WorkerEngineOptions): BergamotEngine
options— Runtime files, workers per route, routes kept loaded.
Returns: The engine.
toBergamotLanguage(code)
Reduces a caller's language code to the form Bergamot's model list uses.
function toBergamotLanguage(code: string): string
code— Any BCP-47-ish code (de,DE,pt-BR,zh_TW,EN-US).
Returns: The model-list code (de, pt, zh-Hant, en).
languageName(code)
English name of a language, for getSupportedLanguages.
function languageName(code: string): string
code— A model-list code.
Returns: The English name, or the code itself when the runtime has no name for it.
escapeHtml(text)
Escapes the HTML-significant characters of plain text.
function escapeHtml(text: string): string
text— Plain text.
Returns: The text with &, <, > escaped.
decodeEntities(text)
Decodes named and numeric character entities back to plain text.
function decodeEntities(text: string): string
text— Text returned by an HTML-mode translation.
Returns: Plain text.
findProtected(text, protect)
Finds every protected occurrence in a text, in order.
function findProtected(text: string, protect: string[]): ProtectedSpan[]
text— The caller's text.protect— Literal substrings to protect.
Returns: The occurrences with their surrounding spacing.
maskTokens(text, protect)
Masks protected substrings as plain-text ⟦N⟧ tokens.
function maskTokens(text: string, protect: string[]): string
text— The caller's text.protect— Literal substrings to protect.
Returns: The masked text.
tokensIntact(translated, count)
Whether a token-masked translation carries every token exactly once and no others.
function tokensIntact(translated: string, count: number): boolean
translated— The engine's output.count— How many protected substrings the source had.
Returns: True when ⟦0⟧…⟦count-1⟧ each appear once.
unmaskTokens(translated, spans)
Restores ⟦N⟧ tokens by index.
function unmaskTokens(translated: string, spans: ProtectedSpan[]): string
translated— The engine's output.spans— The source's protected substrings.
Returns: The restored text.
maskHtml(text, protect, callerMarkup)
Masks protected substrings as empty <x id="N"></x> elements for HTML mode.
function maskHtml(text: string, protect: string[], callerMarkup: boolean): string
text— The caller's text.protect— Literal substrings to protect.callerMarkup— True when the text is already markup (the caller settagHandling).
Returns: HTML for the engine.
unmaskHtml(translated, spans, source, callerMarkup)
Restores <x id="N"></x> elements by index and rebuilds the spacing around
them (see the module description); entities are decoded first for plain text.
function unmaskHtml(
translated: string,
spans: ProtectedSpan[],
source: string,
callerMarkup: boolean,
): string
translated— The engine's HTML output.spans— The source's protected substrings.source— The caller's original text.callerMarkup— True when the caller settagHandling(output stays markup).
Returns: The restored text.
createProvider(config)
Creates a Bergamot translation provider.
function createProvider(config?: BergamotConfig): AITranslationProvider
config— Cache directory, worker count, mirrors, or an injected engine.
Returns: An AITranslationProvider backed by Firefox Translations models.
matchesServer(expression)
Evaluates a Remote Settings targeting rule of the forms Firefox's model list
uses (env.x == 'v', !=, joined by || and &&) for a server.
Anything else is treated as not matching, so an unknown rule never selects a model.
function matchesServer(expression: string | undefined): boolean
expression— The record'sfilter_expression.
Returns: Whether the record applies to a server.
selectModelSet(records, from, to)
Picks the newest complete, released model set for a pair that applies to a server.
A set is complete when it has a model, a lexical shortlist, and either one shared vocabulary or a source + target pair. Files are never mixed across versions.
function selectModelSet(records: ModelRecord[], from: string, to: string): ModelSet | null
records— Firefox's model list.from— Source language (model-list code).to— Target language (model-list code).
Returns: The set, or null when Firefox publishes no usable model for the pair.
availablePairs(records)
Every language pair the list has a usable model for.
function availablePairs(records: ModelRecord[]): [string, string][]
records— Firefox's model list.
Returns: Pairs as [from, to].
downloadVerified(fetchImpl, url, sha256, dest)
Downloads a file once, verifies its SHA-256, and moves it into place atomically. A file already on disk is used as is: it only got there through this hash check (downloads land under a temporary name and are renamed once verified).
function downloadVerified(
fetchImpl: typeof fetch,
url: string,
sha256: string,
dest: string,
): Promise<string>
fetchImpl— Thefetchto use.url— Where to download from.sha256— Expected hash.dest— Final path.
Returns: dest.
Constants
provider
The provider implementation.
const provider: AITranslationProvider
DEFAULT_RECORDS_URL
Firefox's model list.
const DEFAULT_RECORDS_URL: 'https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/translations-models/records'
DEFAULT_ATTACHMENTS_URL
Firefox's model file CDN.
const DEFAULT_ATTACHMENTS_URL: 'https://firefox-settings-attachments.cdn.mozilla.net/'
BERGAMOT_RUNTIME
The Bergamot runtime this bond is built against. The WASM comes from Firefox's
translations-wasm collection (record version 3.0, release v0.6.0); the loader
is the one Firefox itself ships beside it (toolkit/components/translations/ bergamot-translator/bergamot-translator.js), pinned to a Firefox commit. Both
are MPL-2.0, from github.com/mozilla/translations.
const BERGAMOT_RUNTIME: {
readonly release: 'v0.6.0'
readonly wasm: {
readonly path: 'main-workspace/translations-wasm/05082c31-aee8-4249-9e01-c1865afd7520.wasm'
readonly sha256: 'a3a89d9ad0a4ed8f27bf3e403701b23f5709816f6376438503f2fa5b0182c2dc'
readonly filename: 'bergamot-translator-v0.6.0.wasm'
}
readonly glue: {
readonly url: 'https://raw.githubusercontent.com/mozilla-firefox/firefox/48d55cf7ec80093903e2ef7f58b61a84a22ef716/toolkit/components/translations/bergamot-translator/bergamot-translator.js'
readonly sha256: 'faff1ef6285b0d26f01787776fd49299dfb756ecb9688aa990c250e66797b47d'
readonly filename: 'bergamot-translator-v0.6.0.js'
}
}
MAX_MODEL_MAJOR_VERSION
Newest model major version the v0.6.0 runtime can run. 1.x ("tiny") and 2.x ("base" and CJK) models run on it; 3.x models are zstd-compressed and need a newer runtime.
const MAX_MODEL_MAJOR_VERSION: 2
Core Interface
Implements @molecule/api-ai-translation interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/api-ai-translation'
import { provider } from '@molecule/api-ai-translation-bergamot'
export function setupAiTranslationBergamot(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-ai-translation>=1.1.0
Runtime Dependencies
-
@molecule/api-ai-translation -
Languages: English to and from 53 languages (September 2026) — the pairs Firefox publishes for desktop release builds;
getSupportedLanguages('target')lists them from the live model list. Any other pair, e.g. German→Japanese, goes through English in one call. No English→X model exists for Albanian, Amharic, Armenian, Belarusian, Burmese, Filipino, Georgian, Hausa, Igbo, Irish, Kazakh, Khmer, Kyrgyz, Lao, Macedonian, Maltese, Mongolian, Nepali, Punjabi, Sinhala, Swahili, Uzbek, Welsh, Yoruba or Zulu: those throw an error withcode: 'UNSUPPORTED_LANGUAGE_PAIR'— route them to another provider (e.g.@molecule/api-ai-translation-madlador-llm). -
sourceLangdefaults to English — there is no language detection. Codes are normalised (DE,pt-BR,es-MX,zh-TW,noall work; Chinese maps to thezh-Hans/zh-Hantmodels). -
protectplaceholders go out as⟦N⟧tokens and are restored by index. A text whose tokens do not all survive is translated again in Bergamot's HTML mode, which never drops a tag; the spacing around it is then rebuilt from the source. Still check every result before storing it — the model may move a placeholder to a grammatically odd spot. -
First call downloads: the model list, a 5 MB runtime (
bergamot-translatorv0.6.0, SHA-256 pinned), and each pair's files (hash-checked) intocacheDir(BERGAMOT_CACHE_DIR, default~/.cache/molecule/bergamot). Put it on a persistent volume. For hosts without internet, copy the cache directory, or passwasmPath+gluePathand mirror the model list withrecordsUrl/attachmentsUrl. -
Speed and memory: each language pair gets its own worker threads (
workers,BERGAMOT_WORKERS, default 1), each holding the runtime and that pair's model(s) — roughly 100–250 MB per worker; up tomaxLoadedPairspairs (default 4) stay loaded. More workers split a large batch across CPU cores. -
formality,glossaryId,context,modelTypeandpreserveFormattinghave no Bergamot equivalent and are ignored.tagHandling('html' or 'xml') keeps the text's own tags. -
Licences: the models and the runtime are Mozilla's, MPL-2.0 (fine for commercial use; they are downloaded, not bundled). Quality is below Google/DeepL (Mozilla's COMET22 0.870 vs Google 0.902 on the same test set) — spot-check what matters.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip. The sandbox has a live AI provider, so translations run for real; output is non-deterministic, so assert on the resulting LANGUAGE/meaning, never an exact string:
- Translating real text to a target language through the UI returns text ACTUALLY in that language — English→Spanish produces recognizably Spanish, not the original echoed back or left in English.
- Switching the target language (from the picker populated by getSupportedLanguages('target')) changes the output language for the same input — the same source re-translates into the newly chosen language.
- With sourceLang omitted the provider auto-detects: a known-language input comes back with the correct detectedSourceLang, and if the UI shows a detected-language label it names the right one.
- Text already in the target language is left sensible — unchanged or a valid paraphrase, never mangled, doubled, or emptied.
- Empty or untranslatable input (whitespace, emoji, a bare code snippet) is handled gracefully — a clear UI state, nothing crashes.
- A provider failure (bad key, quota exhausted, network drop) surfaces a visible error in the UI, not an unhandled 500 or a silently blank result.
- The translate call runs server-side only — the provider key never reaches the browser (check the network panel: no key in any request).