@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-bergamot

npm · 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.ts JSDoc, 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 set tagHandling).

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 set tagHandling (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's filter_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 — The fetch to 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 with code: 'UNSUPPORTED_LANGUAGE_PAIR' — route them to another provider (e.g. @molecule/api-ai-translation-madlad or -llm).

  • sourceLang defaults to English — there is no language detection. Codes are normalised (DE, pt-BR, es-MX, zh-TW, no all work; Chinese maps to the zh-Hans / zh-Hant models).

  • protect placeholders 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-translator v0.6.0, SHA-256 pinned), and each pair's files (hash-checked) into cacheDir (BERGAMOT_CACHE_DIR, default ~/.cache/molecule/bergamot). Put it on a persistent volume. For hosts without internet, copy the cache directory, or pass wasmPath + gluePath and mirror the model list with recordsUrl / 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 to maxLoadedPairs pairs (default 4) stay loaded. More workers split a large batch across CPU cores.

  • formality, glossaryId, context, modelType and preserveFormatting have 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).