mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 21:29:20 +02:00
fetch_models.py sorted versions with float(), and Firefox Remote Settings
now publishes Nightly pre-releases ("1.0a1") alongside each release. The
first one crashed the whole fetch, so adding Swedish, or 21 of the 34 named
languages, silently did nothing and the button never appeared.
Only take records desktop Firefox would use on the release channel, pick
the newest complete version per direction instead of mixing files across
versions, support pairs that publish a separate source and target vocab
(Japanese), and keep one failing language from stopping the rest.
That opens 50 languages, up from the ~40 the docs claimed, including
Arabic, Japanese, Korean and Thai, which the docs said were unavailable.
All 50 downloaded, loaded and translated on the bergamot 0.4.5 engine in
the shipped image (NOMAD3). Chinese stays out: it is published as
zh-Hans/zh-Hant, which the proxy's two-letter codes cannot carry yet.
Translated pages now send Cache-Control: no-cache and put the installed
language set in the ETag. Kiwix's max-age=3600 kept the old language bar
for up to an hour after a language was added, and a normal refresh does
not reach the article frame.
Docs: step-by-step instructions and the full code table in the Supply
Depot section; sizes corrected to 45-140 MB per language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
7.1 KiB
Python
199 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Fetch Bergamot translation models from Firefox Remote Settings.
|
|
|
|
Deliberately NOT `mozilla/firefox-translations-models` on GitHub: that repo was
|
|
archived on 2026-08-21 and its README now points elsewhere. Remote Settings is
|
|
the endpoint Firefox itself uses, so it stays current.
|
|
|
|
Each language pair needs its attachments (model, lex, and one shared vocab or a
|
|
separate source and target vocab) plus a small marian config that we write
|
|
ourselves. Models are MPL-2.0.
|
|
|
|
Only releases desktop Firefox would use are taken: pre-releases ("1.0a1") are
|
|
gated to Nightly, and some models are Android-only. As of 2026-10 that leaves
|
|
50 languages with a release in both directions, from about 45 MB (Swedish) to
|
|
about 140 MB (Korean) per language. Chinese is published under region codes
|
|
(zh-Hans, zh-Hant) that the proxy's two-letter language codes cannot carry yet.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
RECORDS_URL = (
|
|
"https://firefox.settings.services.mozilla.com/v1/buckets/main"
|
|
"/collections/translations-models/records"
|
|
)
|
|
ATTACHMENT_BASE = "https://firefox-settings-attachments.cdn.mozilla.net/"
|
|
|
|
# Written next to the model files. Bergamot reads this, not the records.
|
|
CONFIG = """\
|
|
relative-paths: true
|
|
models:
|
|
- model.{pair}.bin
|
|
vocabs:
|
|
- {src_vocab}
|
|
- {trg_vocab}
|
|
shortlist:
|
|
- lex.{pair}.bin
|
|
- false
|
|
beam-size: 1
|
|
normalize: 1.0
|
|
word-penalty: 0
|
|
max-length-break: 128
|
|
mini-batch-words: 1024
|
|
workspace: 128
|
|
max-length-factor: 2.0
|
|
skip-cost: true
|
|
cpu-threads: 0
|
|
quiet: true
|
|
quiet-translation: true
|
|
gemm-precision: int8shiftAlphaAll
|
|
alignment: soft
|
|
"""
|
|
|
|
|
|
def records() -> list[dict]:
|
|
with urllib.request.urlopen(RECORDS_URL, timeout=60) as response:
|
|
return json.load(response)["data"]
|
|
|
|
|
|
# A record is for us only if desktop Firefox on the release channel would use
|
|
# it. The rest are pre-releases ("1.0a1") gated to Nightly, or Android builds.
|
|
# Picking them up is how a pre-release version string once crashed the fetch.
|
|
RELEASE_FILTERS = {"", "env.appinfo.OS != 'Android' || env.channel != 'release'"}
|
|
|
|
|
|
def release_version(record: dict) -> tuple[int, ...] | None:
|
|
"""The record's version as a sortable tuple, or None if it is not for us."""
|
|
if (record.get("filter_expression") or "").strip() not in RELEASE_FILTERS:
|
|
return None
|
|
parts = str(record.get("version", "")).split(".")
|
|
if not all(part.isdigit() for part in parts):
|
|
return None
|
|
return tuple(int(part) for part in parts)
|
|
|
|
|
|
def choose_files(data: list[dict], src: str, dst: str) -> dict[str, dict] | None:
|
|
"""The newest release whose files are all published, as {fileType: record}.
|
|
|
|
The files of one version belong together. Taking the newest model and the
|
|
newest vocab independently could mix two versions that were never trained
|
|
together. Most pairs share one vocab between both sides; some (Japanese,
|
|
Chinese) publish a separate source and target vocab instead.
|
|
"""
|
|
by_version: dict[tuple[int, ...], dict[str, dict]] = {}
|
|
for record in data:
|
|
if record.get("fromLang") != src or record.get("toLang") != dst:
|
|
continue
|
|
version = release_version(record)
|
|
if version is not None:
|
|
by_version.setdefault(version, {})[record.get("fileType")] = record
|
|
|
|
for version in sorted(by_version, reverse=True):
|
|
files = by_version[version]
|
|
if "model" not in files or "lex" not in files:
|
|
continue
|
|
if "vocab" in files:
|
|
return {kind: files[kind] for kind in ("model", "lex", "vocab")}
|
|
if "srcvocab" in files and "trgvocab" in files:
|
|
return {kind: files[kind] for kind in ("model", "lex", "srcvocab", "trgvocab")}
|
|
return None
|
|
|
|
|
|
def fetch_pair(data: list[dict], src: str, dst: str, out_root: Path) -> bool:
|
|
"""Download one direction. Returns False if the pair is not published."""
|
|
pair = f"{src}{dst}"
|
|
target = out_root / pair
|
|
|
|
wanted = choose_files(data, src, dst)
|
|
if wanted is None:
|
|
print(f" {pair}: no released model published, skipping pair", flush=True)
|
|
return False
|
|
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
suffix = {"model": "bin", "lex": "bin", "vocab": "spm", "srcvocab": "spm", "trgvocab": "spm"}
|
|
|
|
for kind, record in wanted.items():
|
|
dest = target / f"{kind}.{pair}.{suffix[kind]}"
|
|
expected = record["attachment"]["size"]
|
|
# Resume is not worth the complexity here: a partial file is simply
|
|
# re-fetched. What matters is never leaving a truncated model in place
|
|
# that would fail cryptically at translation time.
|
|
if dest.exists() and dest.stat().st_size == expected:
|
|
continue
|
|
url = ATTACHMENT_BASE + record["attachment"]["location"]
|
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
with urllib.request.urlopen(url, timeout=300) as response:
|
|
tmp.write_bytes(response.read())
|
|
if tmp.stat().st_size != expected:
|
|
tmp.unlink(missing_ok=True)
|
|
raise RuntimeError(
|
|
f"{pair}/{kind}: expected {expected} bytes, got a short read"
|
|
)
|
|
tmp.rename(dest)
|
|
|
|
if "vocab" in wanted:
|
|
src_vocab = trg_vocab = f"vocab.{pair}.spm"
|
|
else:
|
|
src_vocab, trg_vocab = f"srcvocab.{pair}.spm", f"trgvocab.{pair}.spm"
|
|
(target / "config.yml").write_text(
|
|
CONFIG.format(pair=pair, src_vocab=src_vocab, trg_vocab=trg_vocab)
|
|
)
|
|
size = sum(f.stat().st_size for f in target.iterdir()) / 1_000_000
|
|
print(f" {pair}: ready ({size:.0f} MB)", flush=True)
|
|
return True
|
|
|
|
|
|
def main() -> int:
|
|
out_root = Path(os.environ.get("MODELS", "/models"))
|
|
# Comma-separated language codes to pair with English, both directions.
|
|
langs = [
|
|
code.strip().lower()
|
|
for code in os.environ.get("TRANSLATE_LANGS", "fr,es,de").split(",")
|
|
if code.strip()
|
|
]
|
|
|
|
if not langs:
|
|
print("No TRANSLATE_LANGS configured, nothing to fetch.", flush=True)
|
|
return 0
|
|
|
|
out_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Skip the network entirely when every requested pair is already present,
|
|
# so a restart on a genuinely offline box does not fail.
|
|
missing = [
|
|
code
|
|
for code in langs
|
|
if not (out_root / f"en{code}" / "config.yml").exists()
|
|
or not (out_root / f"{code}en" / "config.yml").exists()
|
|
]
|
|
if not missing:
|
|
print(f"All {len(langs)} language pairs already present.", flush=True)
|
|
return 0
|
|
|
|
print(f"Fetching translation models for: {', '.join(missing)}", flush=True)
|
|
try:
|
|
data = records()
|
|
except Exception as exc:
|
|
# An offline box with some pairs already downloaded should still start
|
|
# and serve those, rather than refusing to boot.
|
|
print(f"Could not reach Firefox Remote Settings: {exc}", flush=True)
|
|
return 0
|
|
|
|
for code in missing:
|
|
# One language failing must not stop the ones after it from loading.
|
|
try:
|
|
fetch_pair(data, "en", code, out_root)
|
|
fetch_pair(data, code, "en", out_root)
|
|
except Exception as exc:
|
|
print(f" {code}: download failed, skipping: {exc}", flush=True)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|