Spaces:
Sleeping
Sleeping
current changes + deployment
Browse files- .gitignore +2 -0
- Dockerfile +36 -0
- backend/.env.example +25 -0
- backend/Dockerfile +15 -0
- backend/HUGGINGFACE_DEPLOYMENT.md +114 -0
- backend/__pycache__/deploy_hf.cpython-314.pyc +0 -0
- backend/__pycache__/main.cpython-314.pyc +0 -0
- backend/convert_model.py +46 -0
- backend/deploy_hf.py +76 -0
- backend/languages.py +229 -0
- backend/main.py +105 -0
- backend/providers/__init__.py +53 -0
- backend/providers/__pycache__/__init__.cpython-314.pyc +0 -0
- backend/providers/__pycache__/nllb.cpython-314.pyc +0 -0
- backend/providers/base.py +56 -0
- backend/providers/gemini.py +56 -0
- backend/providers/groq.py +74 -0
- backend/providers/madlad.py +106 -0
- backend/providers/nllb.py +83 -0
- backend/providers/ollama.py +65 -0
- backend/requirements-convert.txt +4 -0
- backend/requirements.txt +14 -0
- docker-compose.yml +23 -0
- requirements.txt +4 -0
.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
|