arindae commited on
Commit
034506e
·
1 Parent(s): e1b031b

current changes + deployment

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ translator_app/.venv
2
+ translator_app
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI model-serving container for Hugging Face Docker Spaces.
2
+ # The Django project owns the user-facing frontend.
3
+
4
+ # ---- Stage 1: convert NLLB to CTranslate2 int8 (needs torch, build-only) ----
5
+ FROM python:3.11-slim AS model
6
+ WORKDIR /m
7
+ COPY backend/requirements.txt backend/requirements-convert.txt backend/convert_model.py ./
8
+ RUN pip install --no-cache-dir -r requirements.txt \
9
+ && pip install --no-cache-dir -r requirements-convert.txt --extra-index-url https://download.pytorch.org/whl/cpu
10
+ # CTranslate2 wheels ship a .so that requests an executable stack, which hardened
11
+ # container runtimes (incl. HuggingFace Spaces) refuse. Clear the flag.
12
+ RUN apt-get update && apt-get install -y --no-install-recommends patchelf \
13
+ && find /usr/local/lib/python3.11/site-packages -name '*.so*' -path '*ctranslate2*' -exec patchelf --clear-execstack {} + \
14
+ && apt-get purge -y patchelf && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
15
+ RUN python convert_model.py # writes ./models/nllb-200-distilled-600M-int8
16
+ RUN HF_MODEL=facebook/nllb-200-distilled-1.3B \
17
+ CT2_MODEL_DIR=models/nllb-200-distilled-1.3B-int8 \
18
+ python convert_model.py
19
+ # MADLAD-400: fetch the pre-converted CTranslate2 int8 (model + tokenizer, ~3 GB)
20
+ RUN python -c "from huggingface_hub import snapshot_download; snapshot_download('Nextcloud-AI/madlad400-3b-mt-ct2-int8', local_dir='models/madlad400-3b-mt-int8')"
21
+
22
+ # ---- Stage 2: lean API runtime (no torch) ----
23
+ FROM python:3.11-slim AS runtime
24
+ WORKDIR /app
25
+ ENV HF_HUB_DISABLE_SYMLINKS_WARNING=1 \
26
+ MADLAD_HF_MODEL=/app/models/madlad400-3b-mt-int8
27
+ COPY backend/requirements.txt ./
28
+ RUN pip install --no-cache-dir -r requirements.txt
29
+ # Same execstack fix for the runtime inference library.
30
+ RUN apt-get update && apt-get install -y --no-install-recommends patchelf \
31
+ && find /usr/local/lib/python3.11/site-packages -name '*.so*' -path '*ctranslate2*' -exec patchelf --clear-execstack {} + \
32
+ && apt-get purge -y patchelf && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
33
+ COPY backend/ ./
34
+ COPY --from=model /m/models ./models
35
+ EXPOSE 7860
36
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
backend/.env.example ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env (local) or set as HuggingFace Space secrets.
2
+ # Every engine is optional — the app exposes whichever ones are configured.
3
+
4
+ # Google Gemini (free tier, no credit card): https://aistudio.google.com/apikey
5
+ GEMINI_API_KEY=
6
+ # GEMINI_MODEL=gemini-2.5-flash-lite
7
+
8
+ # Groq (free tier, no credit card): https://console.groq.com/keys
9
+ GROQ_API_KEY=
10
+ # GROQ_MODEL=llama-3.3-70b-versatile
11
+
12
+ # Local NLLB / MADLAD (CTranslate2). Defaults are fine; override for GPU:
13
+ # CT2_DEVICE=cuda
14
+ # CT2_COMPUTE_TYPE=int8
15
+
16
+ # Local LLM via Ollama (unlocks Qwen / Gemma / TranslateGemma / Aya / Llama).
17
+ # Install Ollama, then `ollama pull <model>`. Engine appears when Ollama runs.
18
+ # OLLAMA_MODEL=qwen2.5
19
+ # OLLAMA_HOST=http://localhost:11434
20
+
21
+ # MADLAD-400 (local, 400+ languages). Convert once (see README), then it appears:
22
+ # MADLAD_MODEL_DIR=models/madlad400-3b-mt-int8
23
+
24
+ # Comma-separated allowed origins for the frontend (dev: http://localhost:3000)
25
+ # CORS_ORIGINS=*
backend/Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+
7
+ # Only what convert_model.py actually needs — copy this BEFORE the rest of the app
8
+ COPY convert_model.py .
9
+ RUN python convert_model.py
10
+
11
+ # Now copy everything else — app code changes no longer touch the layer above
12
+ COPY . .
13
+
14
+ EXPOSE 8000
15
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
backend/HUGGINGFACE_DEPLOYMENT.md ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploy the model API to Hugging Face Spaces
2
+
3
+ This guide deploys the FastAPI model server in `backend/` as a Hugging Face
4
+ Docker Space. The Space is API-only: Django remains responsible for the user
5
+ interface.
6
+
7
+ ## Before you begin
8
+
9
+ - A Hugging Face account with a write access token.
10
+ - Git and Docker available locally if you will build or test the image yourself.
11
+ - Enough Space hardware and storage for the included model artifacts. The image
12
+ builds NLLB-200 600M, NLLB-200 1.3B, and MADLAD-400 3B; the first build can
13
+ take a long time and download several GB.
14
+
15
+ ## Create the Space
16
+
17
+ 1. On [Hugging Face Spaces](https://huggingface.co/new-space), create a new
18
+ Space.
19
+ 2. Choose **Docker** for the SDK and select hardware appropriate for model
20
+ inference. CPU is suitable for NLLB but will be slower; MADLAD is much more
21
+ practical on a larger CPU or GPU Space.
22
+ 3. Give the Space a name, such as `translator-model-api`.
23
+
24
+ The repository-root `Dockerfile` is the file Hugging Face builds. It exposes
25
+ port `7860`, which matches the Space `app_port` in the deployment script.
26
+
27
+ ## Deploy from this repository
28
+
29
+ From the repository root, set your token and Space identifier, then run:
30
+
31
+ ```bash
32
+ HF_TOKEN=hf_your_write_token \
33
+ SPACE_ID=your-hf-user/translator-model-api \
34
+ python backend/deploy_hf.py
35
+ ```
36
+
37
+ The helper creates the Space when needed, uploads the source without local
38
+ model/cache files, and writes Docker Space front matter into the Space README.
39
+ Alternatively, push this repository to the Space Git remote; Hugging Face will
40
+ run the same root `Dockerfile`.
41
+
42
+ ## Wait for the build
43
+
44
+ Open the Space’s **Logs** tab. A successful build starts Uvicorn and binds to
45
+ port `7860`. The first build converts the NLLB checkpoints and downloads the
46
+ MADLAD CTranslate2 model, so it is substantially slower than later rebuilds.
47
+
48
+ When the Space is running, its public API base looks like this:
49
+
50
+ ```text
51
+ https://your-hf-user-translator-model-api.hf.space
52
+ ```
53
+
54
+ ## Verify the API
55
+
56
+ Check that an engine is available:
57
+
58
+ ```bash
59
+ curl https://your-hf-user-translator-model-api.hf.space/api/health
60
+ curl https://your-hf-user-translator-model-api.hf.space/api/engines
61
+ ```
62
+
63
+ Send a translation request using the backend’s FLORES-200 language codes:
64
+
65
+ ```bash
66
+ curl -X POST https://your-hf-user-translator-model-api.hf.space/api/translate \
67
+ -H 'Content-Type: application/json' \
68
+ -d '{
69
+ "text": "Hello, how are you?",
70
+ "source": "eng_Latn",
71
+ "target": "swh_Latn",
72
+ "engine": "nllb"
73
+ }'
74
+ ```
75
+
76
+ Expected response shape:
77
+
78
+ ```json
79
+ {
80
+ "translation": "…",
81
+ "source": "eng_Latn",
82
+ "target": "swh_Latn",
83
+ "engine": "nllb"
84
+ }
85
+ ```
86
+
87
+ Available engine identifiers are `nllb`, `nllb_1_3b`, and `madlad`. Confirm
88
+ their availability through `/api/engines`; an engine is only usable after its
89
+ model directory exists in the container.
90
+
91
+ ## Connect Django
92
+
93
+ In `translator_app/.env`, set the translation endpoint to the deployed Space:
94
+
95
+ ```env
96
+ MODEL_API_URL=https://your-hf-user-translator-model-api.hf.space/api/translate
97
+ TRANSLATION_DEFAULT_MODEL=nllb_600m
98
+ ```
99
+
100
+ Restart Django after changing `.env`. The app maps its UI languages to
101
+ FLORES-200 codes, sends the selected model engine to the Space, and stores the
102
+ model choice in history.
103
+
104
+ ## Troubleshooting
105
+
106
+ - **Build runs out of disk or memory:** choose larger Space hardware/storage,
107
+ or remove unneeded model build steps from the root `Dockerfile`.
108
+ - **`No translation engine is configured`:** inspect `/api/engines` and Space
109
+ logs; the model directory was not built or copied correctly.
110
+ - **Django reports the ML service cannot complete the request:** verify
111
+ `MODEL_API_URL`, confirm the Space is running, and test `/api/translate` with
112
+ `curl` first.
113
+ - **Slow first request:** models load lazily on their first translation. Keep
114
+ the Space awake or send a small warm-up request after deployment.
backend/__pycache__/deploy_hf.cpython-314.pyc ADDED
Binary file (2.82 kB). View file
 
backend/__pycache__/main.cpython-314.pyc ADDED
Binary file (6.51 kB). View file
 
backend/convert_model.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download the NLLB-200 model from HuggingFace and convert it to CTranslate2
2
+ int8 format for fast, low-memory CPU inference.
3
+
4
+ Run once before starting the server:
5
+
6
+ python convert_model.py
7
+
8
+ This produces ~600 MB in ``models/nllb-200-distilled-600M-int8`` (vs ~2.4 GB
9
+ for the fp32 HuggingFace checkpoint).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import subprocess
16
+ import sys
17
+
18
+ HF_MODEL = os.environ.get("HF_MODEL", "facebook/nllb-200-distilled-600M")
19
+ OUT_DIR = os.environ.get("CT2_MODEL_DIR", "models/nllb-200-distilled-600M-int8")
20
+ QUANTIZATION = os.environ.get("CT2_COMPUTE_TYPE", "int8")
21
+
22
+
23
+ def main() -> int:
24
+ if os.path.isdir(OUT_DIR):
25
+ print(f"Model already converted at '{OUT_DIR}'. Nothing to do.")
26
+ return 0
27
+
28
+ os.makedirs(os.path.dirname(OUT_DIR) or ".", exist_ok=True)
29
+ # Invoke via `-m` so it works without the Scripts dir on PATH (Windows venvs).
30
+ cmd = [
31
+ sys.executable, "-m", "ctranslate2.converters.transformers",
32
+ "--model", HF_MODEL,
33
+ "--output_dir", OUT_DIR,
34
+ "--quantization", QUANTIZATION,
35
+ ]
36
+ print("Running:", " ".join(cmd))
37
+ result = subprocess.run(cmd)
38
+ if result.returncode == 0:
39
+ print(f"\nDone. CTranslate2 model written to '{OUT_DIR}'.")
40
+ else:
41
+ print("\nConversion failed.", file=sys.stderr)
42
+ return result.returncode
43
+
44
+
45
+ if __name__ == "__main__":
46
+ raise SystemExit(main())
backend/deploy_hf.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deploy this repo to a HuggingFace Space (Docker SDK).
2
+
3
+ Usage:
4
+ HF_TOKEN=hf_xxx SPACE_ID=<user>/nllb-translator python deploy_hf.py
5
+
6
+ Creates the Space if needed, uploads the repo (excluding heavy/ignored dirs),
7
+ and writes a Space README with the required Docker-Space frontmatter.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+
15
+ from huggingface_hub import HfApi
16
+
17
+ TOKEN = os.environ.get("HF_TOKEN")
18
+ if not TOKEN:
19
+ sys.exit("Set HF_TOKEN (a write token from https://huggingface.co/settings/tokens)")
20
+
21
+ api = HfApi(token=TOKEN)
22
+ user = api.whoami()["name"]
23
+ space_id = os.environ.get("SPACE_ID", f"{user}/nllb-translator")
24
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25
+
26
+ FRONTMATTER = """---
27
+ title: NLLB Translator w/ django
28
+ emoji: :)
29
+ colorFrom: indigo
30
+ colorTo: blue
31
+ sdk: docker
32
+ app_port: 7860
33
+ pinned: false
34
+ license: mit
35
+ ---
36
+
37
+ """
38
+
39
+ IGNORE = [
40
+ ".git/**",
41
+ "_reference/**",
42
+ "**/__pycache__/**",
43
+ "backend/.venv/**",
44
+ "backend/models/**",
45
+ "**/*.pyc",
46
+ ]
47
+
48
+ print(f"Deploying to Space: {space_id}")
49
+ api.create_repo(space_id, repo_type="space", space_sdk="docker", exist_ok=True)
50
+
51
+ # Upload the repo tree (skip README — replaced below with a frontmatter version).
52
+ api.upload_folder(
53
+ folder_path=repo_root,
54
+ repo_id=space_id,
55
+ repo_type="space",
56
+ ignore_patterns=IGNORE + ["README.md"],
57
+ commit_message="Deploy multi-engine NLLB translator",
58
+ )
59
+
60
+ with open(os.path.join(repo_root, "README.md"), encoding="utf-8") as f:
61
+ readme = FRONTMATTER + f.read()
62
+ api.upload_file(
63
+ path_or_fileobj=readme.encode("utf-8"),
64
+ path_in_repo="README.md",
65
+ repo_id=space_id,
66
+ repo_type="space",
67
+ commit_message="Add Space frontmatter",
68
+ )
69
+
70
+ print(f"\nDone → https://huggingface.co/spaces/{space_id}")
71
+ print(
72
+ "The Space will build the Docker image (downloads + converts the model, ~2.4 GB)."
73
+ )
74
+ print(
75
+ "The Space exposes an API only; configure Django MODEL_API_URL with its /api/translate URL."
76
+ )
backend/languages.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FLORES-200 language codes supported by NLLB-200, mapped to readable names.
2
+
3
+ Each code is `<iso639-3>_<script>` (e.g. ``kor_Hang``). This is the full set of
4
+ languages the NLLB-200 model can translate between.
5
+ """
6
+
7
+ # code -> human-readable English display name
8
+ LANGUAGES: dict[str, str] = {
9
+ "ace_Arab": "Acehnese (Arabic)",
10
+ "ace_Latn": "Acehnese (Latin)",
11
+ "acm_Arab": "Mesopotamian Arabic",
12
+ "acq_Arab": "Ta'izzi-Adeni Arabic",
13
+ "aeb_Arab": "Tunisian Arabic",
14
+ "afr_Latn": "Afrikaans",
15
+ "ajp_Arab": "South Levantine Arabic",
16
+ "aka_Latn": "Akan",
17
+ "amh_Ethi": "Amharic",
18
+ "apc_Arab": "North Levantine Arabic",
19
+ "arb_Arab": "Modern Standard Arabic",
20
+ "arb_Latn": "Modern Standard Arabic (Latin)",
21
+ "ars_Arab": "Najdi Arabic",
22
+ "ary_Arab": "Moroccan Arabic",
23
+ "arz_Arab": "Egyptian Arabic",
24
+ "asm_Beng": "Assamese",
25
+ "ast_Latn": "Asturian",
26
+ "awa_Deva": "Awadhi",
27
+ "ayr_Latn": "Central Aymara",
28
+ "azb_Arab": "South Azerbaijani",
29
+ "azj_Latn": "North Azerbaijani",
30
+ "bak_Cyrl": "Bashkir",
31
+ "bam_Latn": "Bambara",
32
+ "ban_Latn": "Balinese",
33
+ "bel_Cyrl": "Belarusian",
34
+ "bem_Latn": "Bemba",
35
+ "ben_Beng": "Bengali",
36
+ "bho_Deva": "Bhojpuri",
37
+ "bjn_Arab": "Banjar (Arabic)",
38
+ "bjn_Latn": "Banjar (Latin)",
39
+ "bod_Tibt": "Standard Tibetan",
40
+ "bos_Latn": "Bosnian",
41
+ "bug_Latn": "Buginese",
42
+ "bul_Cyrl": "Bulgarian",
43
+ "cat_Latn": "Catalan",
44
+ "ceb_Latn": "Cebuano",
45
+ "ces_Latn": "Czech",
46
+ "cjk_Latn": "Chokwe",
47
+ "ckb_Arab": "Central Kurdish",
48
+ "crh_Latn": "Crimean Tatar",
49
+ "cym_Latn": "Welsh",
50
+ "dan_Latn": "Danish",
51
+ "deu_Latn": "German",
52
+ "dik_Latn": "Southwestern Dinka",
53
+ "dyu_Latn": "Dyula",
54
+ "dzo_Tibt": "Dzongkha",
55
+ "ell_Grek": "Greek",
56
+ "eng_Latn": "English",
57
+ "epo_Latn": "Esperanto",
58
+ "est_Latn": "Estonian",
59
+ "eus_Latn": "Basque",
60
+ "ewe_Latn": "Ewe",
61
+ "fao_Latn": "Faroese",
62
+ "pes_Arab": "Western Persian",
63
+ "fij_Latn": "Fijian",
64
+ "fin_Latn": "Finnish",
65
+ "fon_Latn": "Fon",
66
+ "fra_Latn": "French",
67
+ "fur_Latn": "Friulian",
68
+ "fuv_Latn": "Nigerian Fulfulde",
69
+ "gla_Latn": "Scottish Gaelic",
70
+ "gle_Latn": "Irish",
71
+ "glg_Latn": "Galician",
72
+ "grn_Latn": "Guarani",
73
+ "guj_Gujr": "Gujarati",
74
+ "hat_Latn": "Haitian Creole",
75
+ "hau_Latn": "Hausa",
76
+ "heb_Hebr": "Hebrew",
77
+ "hin_Deva": "Hindi",
78
+ "hne_Deva": "Chhattisgarhi",
79
+ "hrv_Latn": "Croatian",
80
+ "hun_Latn": "Hungarian",
81
+ "hye_Armn": "Armenian",
82
+ "ibo_Latn": "Igbo",
83
+ "ilo_Latn": "Ilocano",
84
+ "ind_Latn": "Indonesian",
85
+ "isl_Latn": "Icelandic",
86
+ "ita_Latn": "Italian",
87
+ "jav_Latn": "Javanese",
88
+ "jpn_Jpan": "Japanese",
89
+ "kab_Latn": "Kabyle",
90
+ "kac_Latn": "Jingpho",
91
+ "kam_Latn": "Kamba",
92
+ "kan_Knda": "Kannada",
93
+ "kas_Arab": "Kashmiri (Arabic)",
94
+ "kas_Deva": "Kashmiri (Devanagari)",
95
+ "kat_Geor": "Georgian",
96
+ "knc_Arab": "Central Kanuri (Arabic)",
97
+ "knc_Latn": "Central Kanuri (Latin)",
98
+ "kaz_Cyrl": "Kazakh",
99
+ "kbp_Latn": "Kabiye",
100
+ "kea_Latn": "Kabuverdianu",
101
+ "khm_Khmr": "Khmer",
102
+ "kik_Latn": "Kikuyu",
103
+ "kin_Latn": "Kinyarwanda",
104
+ "kir_Cyrl": "Kyrgyz",
105
+ "kmb_Latn": "Kimbundu",
106
+ "kon_Latn": "Kikongo",
107
+ "kor_Hang": "Korean",
108
+ "kmr_Latn": "Northern Kurdish",
109
+ "lao_Laoo": "Lao",
110
+ "lvs_Latn": "Standard Latvian",
111
+ "lij_Latn": "Ligurian",
112
+ "lim_Latn": "Limburgish",
113
+ "lin_Latn": "Lingala",
114
+ "lit_Latn": "Lithuanian",
115
+ "lmo_Latn": "Lombard",
116
+ "ltg_Latn": "Latgalian",
117
+ "ltz_Latn": "Luxembourgish",
118
+ "lua_Latn": "Luba-Kasai",
119
+ "lug_Latn": "Ganda",
120
+ "luo_Latn": "Luo",
121
+ "lus_Latn": "Mizo",
122
+ "mag_Deva": "Magahi",
123
+ "mai_Deva": "Maithili",
124
+ "mal_Mlym": "Malayalam",
125
+ "mar_Deva": "Marathi",
126
+ "min_Latn": "Minangkabau",
127
+ "mkd_Cyrl": "Macedonian",
128
+ "plt_Latn": "Plateau Malagasy",
129
+ "mlt_Latn": "Maltese",
130
+ "mni_Beng": "Meitei (Bengali)",
131
+ "khk_Cyrl": "Halh Mongolian",
132
+ "mos_Latn": "Mossi",
133
+ "mri_Latn": "Maori",
134
+ "zsm_Latn": "Standard Malay",
135
+ "mya_Mymr": "Burmese",
136
+ "nld_Latn": "Dutch",
137
+ "nno_Latn": "Norwegian Nynorsk",
138
+ "nob_Latn": "Norwegian Bokmal",
139
+ "npi_Deva": "Nepali",
140
+ "nso_Latn": "Northern Sotho",
141
+ "nus_Latn": "Nuer",
142
+ "nya_Latn": "Nyanja",
143
+ "oci_Latn": "Occitan",
144
+ "gaz_Latn": "West Central Oromo",
145
+ "ory_Orya": "Odia",
146
+ "pag_Latn": "Pangasinan",
147
+ "pan_Guru": "Eastern Panjabi",
148
+ "pap_Latn": "Papiamento",
149
+ "pol_Latn": "Polish",
150
+ "por_Latn": "Portuguese",
151
+ "prs_Arab": "Dari",
152
+ "pbt_Arab": "Southern Pashto",
153
+ "quy_Latn": "Ayacucho Quechua",
154
+ "ron_Latn": "Romanian",
155
+ "run_Latn": "Rundi",
156
+ "rus_Cyrl": "Russian",
157
+ "sag_Latn": "Sango",
158
+ "san_Deva": "Sanskrit",
159
+ "sat_Olck": "Santali",
160
+ "scn_Latn": "Sicilian",
161
+ "shn_Mymr": "Shan",
162
+ "sin_Sinh": "Sinhala",
163
+ "slk_Latn": "Slovak",
164
+ "slv_Latn": "Slovenian",
165
+ "smo_Latn": "Samoan",
166
+ "sna_Latn": "Shona",
167
+ "snd_Arab": "Sindhi",
168
+ "som_Latn": "Somali",
169
+ "sot_Latn": "Southern Sotho",
170
+ "spa_Latn": "Spanish",
171
+ "als_Latn": "Tosk Albanian",
172
+ "srd_Latn": "Sardinian",
173
+ "srp_Cyrl": "Serbian",
174
+ "ssw_Latn": "Swati",
175
+ "sun_Latn": "Sundanese",
176
+ "swe_Latn": "Swedish",
177
+ "swh_Latn": "Swahili",
178
+ "szl_Latn": "Silesian",
179
+ "tam_Taml": "Tamil",
180
+ "tat_Cyrl": "Tatar",
181
+ "tel_Telu": "Telugu",
182
+ "tgk_Cyrl": "Tajik",
183
+ "tgl_Latn": "Tagalog",
184
+ "tha_Thai": "Thai",
185
+ "tir_Ethi": "Tigrinya",
186
+ "taq_Latn": "Tamasheq (Latin)",
187
+ "taq_Tfng": "Tamasheq (Tifinagh)",
188
+ "tpi_Latn": "Tok Pisin",
189
+ "tsn_Latn": "Tswana",
190
+ "tso_Latn": "Tsonga",
191
+ "tuk_Latn": "Turkmen",
192
+ "tum_Latn": "Tumbuka",
193
+ "tur_Latn": "Turkish",
194
+ "twi_Latn": "Twi",
195
+ "tzm_Tfng": "Central Atlas Tamazight",
196
+ "uig_Arab": "Uyghur",
197
+ "ukr_Cyrl": "Ukrainian",
198
+ "umb_Latn": "Umbundu",
199
+ "urd_Arab": "Urdu",
200
+ "uzn_Latn": "Northern Uzbek",
201
+ "vec_Latn": "Venetian",
202
+ "vie_Latn": "Vietnamese",
203
+ "war_Latn": "Waray",
204
+ "wol_Latn": "Wolof",
205
+ "xho_Latn": "Xhosa",
206
+ "ydd_Hebr": "Eastern Yiddish",
207
+ "yor_Latn": "Yoruba",
208
+ "yue_Hant": "Yue Chinese (Cantonese)",
209
+ "zho_Hans": "Chinese (Simplified)",
210
+ "zho_Hant": "Chinese (Traditional)",
211
+ "zul_Latn": "Zulu",
212
+ }
213
+
214
+
215
+ def is_valid(code: str) -> bool:
216
+ return code in LANGUAGES
217
+
218
+
219
+ def name_for(code: str) -> str:
220
+ """Human-readable name for a FLORES-200 code (used in API prompts)."""
221
+ return LANGUAGES.get(code, code)
222
+
223
+
224
+ def as_list() -> list[dict[str, str]]:
225
+ """Return languages as a sorted list of {code, name} for the API."""
226
+ return [
227
+ {"code": code, "name": name}
228
+ for code, name in sorted(LANGUAGES.items(), key=lambda kv: kv[1])
229
+ ]
backend/main.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI service — model-serving API consumed by the Django application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import httpx
8
+ from fastapi import FastAPI, Header, HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from pydantic import BaseModel, Field
11
+
12
+ import providers
13
+ from languages import as_list
14
+
15
+ app = FastAPI(
16
+ title="Translator Model API",
17
+ description="Model-serving API for the Django Translator App.",
18
+ version="2.0.0",
19
+ )
20
+
21
+ _origins = os.environ.get("CORS_ORIGINS", "*").split(",")
22
+ app.add_middleware(
23
+ CORSMiddleware,
24
+ allow_origins=[o.strip() for o in _origins],
25
+ allow_methods=["*"],
26
+ allow_headers=["*"],
27
+ )
28
+
29
+ MAX_INPUT_CHARS = int(os.environ.get("MAX_INPUT_CHARS", "5000"))
30
+
31
+
32
+ class TranslateRequest(BaseModel):
33
+ text: str = Field(..., description="Text to translate.")
34
+ source: str = Field(..., description="Source FLORES-200 code, e.g. 'eng_Latn'.")
35
+ target: str = Field(..., description="Target FLORES-200 code, e.g. 'kor_Hang'.")
36
+ engine: str | None = Field(None, description="Engine id; defaults to first available.")
37
+
38
+
39
+ class TranslateResponse(BaseModel):
40
+ translation: str
41
+ source: str
42
+ target: str
43
+ engine: str
44
+
45
+
46
+ @app.get("/api/health")
47
+ def health() -> dict:
48
+ return {"status": "ok", "default_engine": providers.default_id()}
49
+
50
+
51
+ @app.get("/api/engines")
52
+ def engines() -> dict:
53
+ return {
54
+ "engines": [vars(i) for i in providers.all_infos()],
55
+ "default": providers.default_id(),
56
+ }
57
+
58
+
59
+ @app.get("/api/languages")
60
+ def languages() -> dict:
61
+ return {"languages": as_list()}
62
+
63
+
64
+ @app.post("/api/translate", response_model=TranslateResponse)
65
+ def translate_endpoint(
66
+ req: TranslateRequest,
67
+ x_gemini_key: str | None = Header(default=None),
68
+ x_groq_key: str | None = Header(default=None),
69
+ ) -> TranslateResponse:
70
+ if not req.text.strip():
71
+ return TranslateResponse(
72
+ translation="", source=req.source, target=req.target, engine=req.engine or ""
73
+ )
74
+ if len(req.text) > MAX_INPUT_CHARS:
75
+ raise HTTPException(400, f"Input too long (max {MAX_INPUT_CHARS} chars).")
76
+
77
+ engine_id = req.engine or providers.default_id()
78
+ if not engine_id:
79
+ raise HTTPException(503, "No translation engine is configured.")
80
+ provider = providers.get(engine_id)
81
+ if provider is None:
82
+ raise HTTPException(400, f"Unknown engine: {engine_id!r}")
83
+
84
+ # Client-supplied key (bring-your-own-key) for API engines.
85
+ client_keys = {"gemini": x_gemini_key, "groq": x_groq_key}
86
+ api_key = client_keys.get(provider.key_field) if provider.key_field else None
87
+
88
+ if provider.kind == "api":
89
+ if not (provider.is_available() or api_key):
90
+ raise HTTPException(503, provider.setup_hint or "Add an API key in Settings.")
91
+ elif not provider.is_available():
92
+ raise HTTPException(503, provider.setup_hint or f"Engine '{engine_id}' is not available.")
93
+
94
+ try:
95
+ result = provider.translate(req.text, req.source, req.target, api_key=api_key)
96
+ except ValueError as exc:
97
+ raise HTTPException(400, str(exc)) from exc
98
+ except httpx.HTTPStatusError as exc:
99
+ raise HTTPException(502, f"Upstream API error: {exc.response.status_code}") from exc
100
+ except Exception as exc: # noqa: BLE001 — surface engine errors to the client
101
+ raise HTTPException(500, f"Translation failed: {exc}") from exc
102
+
103
+ return TranslateResponse(
104
+ translation=result, source=req.source, target=req.target, engine=engine_id
105
+ )
backend/providers/__init__.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Registry of translation engines.
2
+
3
+ Add a new engine by implementing ``TranslationProvider`` and appending an
4
+ instance to ``_PROVIDERS`` below. The API automatically exposes whichever ones
5
+ report themselves available.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from providers.base import ProviderInfo, TranslationProvider
11
+ from providers.gemini import GeminiProvider
12
+ from providers.groq import GroqProvider, GroqQwenProvider
13
+ from providers.madlad import MADLADProvider
14
+ from providers.nllb import NLLBProvider
15
+ from providers.ollama import OllamaProvider
16
+
17
+ # Order = display order in the UI. Local/private engines first.
18
+ _PROVIDERS: list[TranslationProvider] = [
19
+ NLLBProvider(),
20
+ NLLBProvider(
21
+ provider_id="nllb_1_3b",
22
+ name="NLLB-200 (1.3B)",
23
+ model_dir="models/nllb-200-distilled-1.3B-int8",
24
+ hf_model="facebook/nllb-200-distilled-1.3B",
25
+ ),
26
+ MADLADProvider(),
27
+ OllamaProvider(),
28
+ GeminiProvider(),
29
+ GroqQwenProvider(),
30
+ GroqProvider(),
31
+ ]
32
+
33
+ _BY_ID = {p.id: p for p in _PROVIDERS}
34
+
35
+
36
+ def all_infos() -> list[ProviderInfo]:
37
+ return [p.info() for p in _PROVIDERS]
38
+
39
+
40
+ def available_infos() -> list[ProviderInfo]:
41
+ return [p.info() for p in _PROVIDERS if p.is_available()]
42
+
43
+
44
+ def get(provider_id: str) -> TranslationProvider | None:
45
+ return _BY_ID.get(provider_id)
46
+
47
+
48
+ def default_id() -> str | None:
49
+ """First available engine, preferring local/private ones."""
50
+ for p in _PROVIDERS:
51
+ if p.is_available():
52
+ return p.id
53
+ return None
backend/providers/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (2.98 kB). View file
 
backend/providers/__pycache__/nllb.cpython-314.pyc ADDED
Binary file (5.78 kB). View file
 
backend/providers/base.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Common interface every translation engine implements.
2
+
3
+ A *provider* is one translation engine — a local model (NLLB, MADLAD…) or a
4
+ free-tier cloud API (Gemini, Groq…). The registry exposes whichever ones are
5
+ actually usable: a local engine is available when its model files exist; an API
6
+ engine is available when its API key is configured (server env) OR supplied by
7
+ the client per-request (bring-your-own-key).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass
16
+ class ProviderInfo:
17
+ id: str
18
+ name: str
19
+ kind: str # "local" | "api"
20
+ description: str
21
+ available: bool # ready from server-side config (env key / local model)
22
+ private: bool # True if text never leaves the machine
23
+ setup_hint: str # what to do when unavailable
24
+ key_field: str # which client key unlocks it ("gemini"/"groq"); "" for local
25
+
26
+
27
+ class TranslationProvider:
28
+ id: str = ""
29
+ name: str = ""
30
+ kind: str = "local"
31
+ description: str = ""
32
+ private: bool = False
33
+ setup_hint: str = ""
34
+ key_field: str = ""
35
+
36
+ def is_available(self) -> bool:
37
+ raise NotImplementedError
38
+
39
+ def translate(self, text: str, src: str, tgt: str, api_key: str | None = None) -> str:
40
+ raise NotImplementedError
41
+
42
+ def display_name(self) -> str:
43
+ """Overridable so engines like Ollama can reflect the active model."""
44
+ return self.name
45
+
46
+ def info(self) -> ProviderInfo:
47
+ return ProviderInfo(
48
+ id=self.id,
49
+ name=self.display_name(),
50
+ kind=self.kind,
51
+ description=self.description,
52
+ available=self.is_available(),
53
+ private=self.private,
54
+ setup_hint=self.setup_hint,
55
+ key_field=self.key_field,
56
+ )
backend/providers/gemini.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google Gemini engine — free API tier, no self-hosting.
2
+
3
+ Uses Gemini's generous free tier (no credit card). Strong on Korean / CJK and
4
+ low-resource prose. Available whenever ``GEMINI_API_KEY`` is set. Text is sent to
5
+ Google, so this engine is not private.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ import httpx
13
+
14
+ from languages import name_for
15
+ from providers.base import TranslationProvider
16
+
17
+ MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash-lite")
18
+ ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models"
19
+
20
+
21
+ class GeminiProvider(TranslationProvider):
22
+ id = "gemini"
23
+ name = "Gemini 2.5 Flash-Lite"
24
+ kind = "api"
25
+ description = "Google Gemini · free API tier · excellent for Korean/CJK"
26
+ private = False
27
+ key_field = "gemini"
28
+ setup_hint = "Add a Gemini key in Settings (free at aistudio.google.com/apikey)"
29
+
30
+ def is_available(self) -> bool:
31
+ return bool(os.environ.get("GEMINI_API_KEY"))
32
+
33
+ def translate(self, text: str, src: str, tgt: str, api_key: str | None = None) -> str:
34
+ key = api_key or os.environ.get("GEMINI_API_KEY")
35
+ if not key:
36
+ raise ValueError("No Gemini API key provided.")
37
+ prompt = (
38
+ f"Translate the following text from {name_for(src)} to "
39
+ f"{name_for(tgt)}. Output ONLY the translation, with no notes, "
40
+ f"quotes, or explanations.\n\n{text}"
41
+ )
42
+ url = f"{ENDPOINT}/{MODEL}:generateContent?key={key}"
43
+ resp = httpx.post(
44
+ url,
45
+ json={
46
+ "contents": [{"parts": [{"text": prompt}]}],
47
+ "generationConfig": {"temperature": 0.2},
48
+ },
49
+ timeout=30.0,
50
+ )
51
+ resp.raise_for_status()
52
+ data = resp.json()
53
+ try:
54
+ return data["candidates"][0]["content"]["parts"][0]["text"].strip()
55
+ except (KeyError, IndexError) as exc:
56
+ raise RuntimeError(f"Unexpected Gemini response: {data}") from exc
backend/providers/groq.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Groq engine(s) — free API tier, ultra-fast LPU inference.
2
+
3
+ Serves open LLMs via an OpenAI-compatible endpoint. One API key (`GROQ_API_KEY`)
4
+ powers every Groq engine; each subclass just points at a different model.
5
+ Text is sent to Groq, so these engines are not private.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ import httpx
13
+
14
+ from languages import name_for
15
+ from providers.base import TranslationProvider
16
+
17
+ ENDPOINT = "https://api.groq.com/openai/v1/chat/completions"
18
+
19
+
20
+ class _GroqEngine(TranslationProvider):
21
+ kind = "api"
22
+ private = False
23
+ key_field = "groq"
24
+ setup_hint = "Add a Groq key in Settings (free at console.groq.com/keys)"
25
+ model = "" # subclass sets this
26
+
27
+ def is_available(self) -> bool:
28
+ return bool(os.environ.get("GROQ_API_KEY"))
29
+
30
+ def translate(self, text: str, src: str, tgt: str, api_key: str | None = None) -> str:
31
+ key = api_key or os.environ.get("GROQ_API_KEY")
32
+ if not key:
33
+ raise ValueError("No Groq API key provided.")
34
+ resp = httpx.post(
35
+ ENDPOINT,
36
+ headers={"Authorization": f"Bearer {key}"},
37
+ json={
38
+ "model": self.model,
39
+ "temperature": 0.2,
40
+ "messages": [
41
+ {
42
+ "role": "system",
43
+ "content": "You are a translation engine. Output only the "
44
+ "translation of the user's text, with no notes or quotes.",
45
+ },
46
+ {
47
+ "role": "user",
48
+ "content": f"Translate from {name_for(src)} to "
49
+ f"{name_for(tgt)}:\n\n{text}",
50
+ },
51
+ ],
52
+ },
53
+ timeout=30.0,
54
+ )
55
+ resp.raise_for_status()
56
+ data = resp.json()
57
+ try:
58
+ return data["choices"][0]["message"]["content"].strip()
59
+ except (KeyError, IndexError) as exc:
60
+ raise RuntimeError(f"Unexpected Groq response: {data}") from exc
61
+
62
+
63
+ class GroqProvider(_GroqEngine):
64
+ id = "groq"
65
+ name = "Llama 3.3 70B (Groq)"
66
+ description = "Open LLM on Groq · free API tier · very fast"
67
+ model = os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile")
68
+
69
+
70
+ class GroqQwenProvider(_GroqEngine):
71
+ id = "groq_qwen"
72
+ name = "Qwen 2.5 32B (Groq)"
73
+ description = "Alibaba Qwen 2.5 on Groq · free API · strong CJK/Korean"
74
+ model = os.environ.get("GROQ_QWEN_MODEL", "qwen-2.5-32b")
backend/providers/madlad.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MADLAD-400 engine — local inference via CTranslate2 (int8).
2
+
3
+ Google's open T5-based MT model covering 400+ languages, generally higher
4
+ quality than NLLB. Heavier than NLLB (3B), so it's a local/GPU engine — not the
5
+ free-CPU-Space default. Convert it once with:
6
+
7
+ HF_MODEL=google/madlad400-3b-mt \\
8
+ CT2_MODEL_DIR=models/madlad400-3b-mt-int8 \\
9
+ python convert_model.py
10
+
11
+ MADLAD selects the target language via a ``<2xx>`` token prefixed to the input;
12
+ the source language is auto-detected.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from functools import lru_cache
19
+ from threading import Lock
20
+
21
+ from providers.base import TranslationProvider
22
+
23
+ MODEL_DIR = os.environ.get("MADLAD_MODEL_DIR", "models/madlad400-3b-mt-int8")
24
+ HF_MODEL = os.environ.get("MADLAD_HF_MODEL", "google/madlad400-3b-mt")
25
+ DEVICE = os.environ.get("CT2_DEVICE", "cpu")
26
+ COMPUTE_TYPE = os.environ.get("CT2_COMPUTE_TYPE", "int8")
27
+ MAX_DECODING_LENGTH = int(os.environ.get("MAX_DECODING_LENGTH", "256"))
28
+
29
+ # FLORES-200 code -> MADLAD-400 language code (common subset).
30
+ FLORES_TO_MADLAD: dict[str, str] = {
31
+ "eng_Latn": "en", "kor_Hang": "ko", "jpn_Jpan": "ja", "zho_Hans": "zh",
32
+ "zho_Hant": "zh", "yue_Hant": "yue", "spa_Latn": "es", "fra_Latn": "fr",
33
+ "deu_Latn": "de", "rus_Cyrl": "ru", "por_Latn": "pt", "ita_Latn": "it",
34
+ "nld_Latn": "nl", "pol_Latn": "pl", "tur_Latn": "tr", "arb_Arab": "ar",
35
+ "hin_Deva": "hi", "ben_Beng": "bn", "vie_Latn": "vi", "tha_Thai": "th",
36
+ "ind_Latn": "id", "zsm_Latn": "ms", "ukr_Cyrl": "uk", "ces_Latn": "cs",
37
+ "ron_Latn": "ro", "ell_Grek": "el", "heb_Hebr": "iw", "swe_Latn": "sv",
38
+ "dan_Latn": "da", "fin_Latn": "fi", "nob_Latn": "no", "hun_Latn": "hu",
39
+ "bul_Cyrl": "bg", "hrv_Latn": "hr", "srp_Cyrl": "sr", "slk_Latn": "sk",
40
+ "slv_Latn": "sl", "lit_Latn": "lt", "lvs_Latn": "lv", "est_Latn": "et",
41
+ "cat_Latn": "ca", "tgl_Latn": "tl", "pes_Arab": "fa", "urd_Arab": "ur",
42
+ "tam_Taml": "ta", "tel_Telu": "te", "mal_Mlym": "ml", "kan_Knda": "kn",
43
+ "mar_Deva": "mr", "guj_Gujr": "gu", "pan_Guru": "pa", "mya_Mymr": "my",
44
+ "khm_Khmr": "km", "lao_Laoo": "lo", "sin_Sinh": "si", "amh_Ethi": "am",
45
+ "swh_Latn": "sw", "yor_Latn": "yo", "ibo_Latn": "ig", "hau_Latn": "ha",
46
+ "zul_Latn": "zu", "afr_Latn": "af", "isl_Latn": "is", "gle_Latn": "ga",
47
+ "cym_Latn": "cy", "eus_Latn": "eu", "glg_Latn": "gl", "kat_Geor": "ka",
48
+ "hye_Armn": "hy", "azj_Latn": "az", "kaz_Cyrl": "kk", "uzn_Latn": "uz",
49
+ "mkd_Cyrl": "mk", "als_Latn": "sq", "bel_Cyrl": "be", "npi_Deva": "ne",
50
+ }
51
+
52
+
53
+ class MADLADProvider(TranslationProvider):
54
+ id = "madlad"
55
+ name = "MADLAD-400 (3B)"
56
+ kind = "local"
57
+ description = "Google's open MT model · 400+ languages · higher quality than NLLB"
58
+ private = True
59
+ setup_hint = "Convert MADLAD locally (see README — heavy, ~3 GB)"
60
+
61
+ def __init__(self) -> None:
62
+ self._translator = None
63
+ self._tokenizer = None
64
+ self._lock = Lock()
65
+
66
+ def is_available(self) -> bool:
67
+ return os.path.isdir(MODEL_DIR)
68
+
69
+ def _ensure_loaded(self) -> None:
70
+ if self._translator is not None:
71
+ return
72
+ with self._lock:
73
+ if self._translator is not None:
74
+ return
75
+ import ctranslate2
76
+ import transformers
77
+
78
+ self._translator = ctranslate2.Translator(
79
+ MODEL_DIR, device=DEVICE, compute_type=COMPUTE_TYPE
80
+ )
81
+ self._tokenizer = transformers.AutoTokenizer.from_pretrained(HF_MODEL)
82
+
83
+ @lru_cache(maxsize=2048)
84
+ def _translate_line(self, text: str, madlad_tgt: str) -> str:
85
+ prompt = f"<2{madlad_tgt}> {text}"
86
+ source = self._tokenizer.convert_ids_to_tokens(self._tokenizer.encode(prompt))
87
+ results = self._translator.translate_batch(
88
+ [source], beam_size=4, max_decoding_length=MAX_DECODING_LENGTH
89
+ )
90
+ target_tokens = results[0].hypotheses[0]
91
+ return self._tokenizer.decode(
92
+ self._tokenizer.convert_tokens_to_ids(target_tokens)
93
+ )
94
+
95
+ def translate(self, text: str, src: str, tgt: str, api_key: str | None = None) -> str:
96
+ madlad_tgt = FLORES_TO_MADLAD.get(tgt)
97
+ if madlad_tgt is None:
98
+ raise ValueError(
99
+ "MADLAD engine doesn't support this target language yet — "
100
+ "try NLLB, or pick a more common language."
101
+ )
102
+ self._ensure_loaded()
103
+ out = []
104
+ for line in text.split("\n"):
105
+ out.append(self._translate_line(line, madlad_tgt) if line.strip() else "")
106
+ return "\n".join(out)
backend/providers/nllb.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NLLB-200 engine — local inference via CTranslate2 (int8).
2
+
3
+ Light enough to run on a free HuggingFace Spaces CPU. Heavy objects load lazily
4
+ on first use and are shared across requests.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from functools import lru_cache
11
+ from threading import Lock
12
+
13
+ from languages import is_valid
14
+ from providers.base import TranslationProvider
15
+
16
+ MODEL_DIR = os.environ.get("CT2_MODEL_DIR", "models/nllb-200-distilled-600M-int8")
17
+ HF_MODEL = os.environ.get("HF_MODEL", "facebook/nllb-200-distilled-600M")
18
+ DEVICE = os.environ.get("CT2_DEVICE", "cpu")
19
+ COMPUTE_TYPE = os.environ.get("CT2_COMPUTE_TYPE", "int8")
20
+ MAX_DECODING_LENGTH = int(os.environ.get("MAX_DECODING_LENGTH", "256"))
21
+
22
+
23
+ class NLLBProvider(TranslationProvider):
24
+ kind = "local"
25
+ description = "Meta's open translation model · 200 languages · runs offline"
26
+ private = True
27
+ setup_hint = "Convert the model: `python convert_model.py`"
28
+
29
+ def __init__(
30
+ self,
31
+ provider_id: str = "nllb",
32
+ name: str = "NLLB-200 (600M)",
33
+ model_dir: str = MODEL_DIR,
34
+ hf_model: str = HF_MODEL,
35
+ ) -> None:
36
+ self.id = provider_id
37
+ self.name = name
38
+ self.model_dir = model_dir
39
+ self.hf_model = hf_model
40
+ self._translator = None
41
+ self._tokenizer = None
42
+ self._lock = Lock()
43
+
44
+ def is_available(self) -> bool:
45
+ return os.path.isdir(self.model_dir)
46
+
47
+ def _ensure_loaded(self) -> None:
48
+ if self._translator is not None:
49
+ return
50
+ with self._lock:
51
+ if self._translator is not None:
52
+ return
53
+ import ctranslate2
54
+ import transformers
55
+
56
+ self._translator = ctranslate2.Translator(
57
+ self.model_dir, device=DEVICE, compute_type=COMPUTE_TYPE
58
+ )
59
+ self._tokenizer = transformers.AutoTokenizer.from_pretrained(self.hf_model)
60
+
61
+ @lru_cache(maxsize=2048)
62
+ def _translate_line(self, text: str, src: str, tgt: str) -> str:
63
+ self._tokenizer.src_lang = src
64
+ source = self._tokenizer.convert_ids_to_tokens(self._tokenizer.encode(text))
65
+ results = self._translator.translate_batch(
66
+ [source],
67
+ target_prefix=[[tgt]],
68
+ beam_size=4,
69
+ max_decoding_length=MAX_DECODING_LENGTH,
70
+ )
71
+ target_tokens = results[0].hypotheses[0][1:] # drop the target-lang token
72
+ return self._tokenizer.decode(
73
+ self._tokenizer.convert_tokens_to_ids(target_tokens)
74
+ )
75
+
76
+ def translate(self, text: str, src: str, tgt: str, api_key: str | None = None) -> str:
77
+ if not is_valid(src) or not is_valid(tgt):
78
+ raise ValueError("Unknown language code for NLLB.")
79
+ self._ensure_loaded()
80
+ out = []
81
+ for line in text.split("\n"):
82
+ out.append(self._translate_line(line, src, tgt) if line.strip() else "")
83
+ return "\n".join(out)
backend/providers/ollama.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ollama engine — any local LLM, fully offline.
2
+
3
+ One provider unlocks a whole shelf of open models the user can run locally via
4
+ Ollama: Qwen, Gemma / TranslateGemma, Aya, Llama, and more. Set the model with
5
+ ``OLLAMA_MODEL`` (default ``qwen2.5``); pull it first with ``ollama pull <model>``.
6
+
7
+ Available whenever a local Ollama server is reachable. Text never leaves the
8
+ machine, so this engine is private.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+
15
+ import httpx
16
+
17
+ from languages import name_for
18
+ from providers.base import TranslationProvider
19
+
20
+ HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
21
+ MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5")
22
+
23
+
24
+ class OllamaProvider(TranslationProvider):
25
+ id = "ollama"
26
+ name = "Local LLM (Ollama)"
27
+ kind = "local"
28
+ description = "Run Qwen / Gemma / TranslateGemma / Aya locally · offline"
29
+ private = True
30
+ setup_hint = "Install Ollama and `ollama pull " + MODEL + "`"
31
+
32
+ def display_name(self) -> str:
33
+ return f"{MODEL} (Ollama)"
34
+
35
+ def is_available(self) -> bool:
36
+ try:
37
+ resp = httpx.get(f"{HOST}/api/tags", timeout=0.4)
38
+ return resp.status_code == 200
39
+ except httpx.HTTPError:
40
+ return False
41
+
42
+ def translate(self, text: str, src: str, tgt: str, api_key: str | None = None) -> str:
43
+ resp = httpx.post(
44
+ f"{HOST}/api/chat",
45
+ json={
46
+ "model": MODEL,
47
+ "stream": False,
48
+ "options": {"temperature": 0.2},
49
+ "messages": [
50
+ {
51
+ "role": "system",
52
+ "content": "You are a translation engine. Output only the "
53
+ "translation of the user's text, with no notes or quotes.",
54
+ },
55
+ {
56
+ "role": "user",
57
+ "content": f"Translate from {name_for(src)} to "
58
+ f"{name_for(tgt)}:\n\n{text}",
59
+ },
60
+ ],
61
+ },
62
+ timeout=120.0,
63
+ )
64
+ resp.raise_for_status()
65
+ return resp.json()["message"]["content"].strip()
backend/requirements-convert.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Extra deps needed ONLY to convert the HF model to CTranslate2 (one-time).
2
+ # Runtime inference does not need torch. Install with the CPU wheel index:
3
+ # pip install -r requirements-convert.txt --extra-index-url https://download.pytorch.org/whl/cpu
4
+ torch==2.5.1
backend/requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API
2
+ fastapi==0.115.6
3
+ uvicorn[standard]==0.34.0
4
+ pydantic==2.10.4
5
+ httpx==0.28.1
6
+
7
+ # Local inference (NLLB via CTranslate2; no torch needed at runtime)
8
+ ctranslate2==4.5.0
9
+ transformers==4.47.1
10
+ sentencepiece==0.2.0
11
+ protobuf==5.29.2
12
+
13
+ # ctranslate2 4.5 imports pkg_resources, removed in setuptools>=81
14
+ setuptools<81
docker-compose.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ backend:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ ports:
7
+ - "7860:7860"
8
+ env_file:
9
+ - path: ./backend/.env
10
+ required: false
11
+ environment:
12
+ - CORS_ORIGINS=http://localhost:8000
13
+ # API engines activate automatically when these are set (see backend/.env)
14
+ - GEMINI_API_KEY=${GEMINI_API_KEY:-}
15
+ - GROQ_API_KEY=${GROQ_API_KEY:-}
16
+ volumes:
17
+ # Persist the converted model + HF cache across rebuilds.
18
+ - model-cache:/app/models
19
+ - hf-cache:/root/.cache/huggingface
20
+
21
+ volumes:
22
+ model-cache:
23
+ hf-cache:
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Django>=5.2
2
+ djangorestframework>=3.15
3
+ python-dotenv>=1.0
4
+ requests>=2.31