initial commit: FastAPI translator proxying to Ollama on radagast

- app.py: /api/models filtered to gemma+aya, /api/translate streams
  NDJSON from Ollama with stream:true, /healthz for k8s probes
- static/index.html: dark-mode UI, three dropdowns (source/target/model),
  live token streaming with spinner+pulse+timer indicators
- Dockerfile: two-stage uv build, slim Python 3.13 runtime
- .gitea/workflows/build.yml: multi-arch (amd64+arm64) build, push to
  Scaleway registry on push to main
- build.sh: manual fallback if Gitea Actions isn't available
This commit is contained in:
João Pedro Battistella Nadas
2026-06-09 15:13:48 +02:00
commit 777c80a0e1
10 changed files with 1236 additions and 0 deletions

91
app.py Normal file
View File

@@ -0,0 +1,91 @@
import os
from pathlib import Path
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://radagast.jpnadas.xyz:11434")
# Comma-separated substrings; a model name matches if it contains any of them.
# Set to empty string to show every installed model.
MODEL_FILTER = os.getenv("MODEL_FILTER", "gemma,aya").lower()
STATIC_DIR = Path(__file__).parent / "static"
app = FastAPI(title="finrod")
class TranslateRequest(BaseModel):
text: str
source: str
target: str
model: str
@app.get("/")
async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/models")
async def list_models() -> list[str]:
async with httpx.AsyncClient(timeout=10) as client:
try:
r = await client.get(f"{OLLAMA_URL}/api/tags")
r.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(502, f"Ollama unreachable: {e}") from e
names = [m["name"] for m in r.json().get("models", [])]
filters = [f.strip() for f in MODEL_FILTER.split(",") if f.strip()]
if filters:
names = [n for n in names if any(f in n.lower() for f in filters)]
return sorted(names)
@app.post("/api/translate")
async def translate(req: TranslateRequest) -> StreamingResponse:
text = req.text.strip()
if not text:
raise HTTPException(400, "text is empty")
if req.source.lower() == "auto":
source_clause = "Detect the source language."
else:
source_clause = f"The source language is {req.source}."
prompt = (
f"You are a translation engine. {source_clause} "
f"Translate the text below into {req.target}. "
"Output ONLY the translation: no quotes, no explanations, no preamble, "
"no language labels. Preserve line breaks and formatting.\n\n"
f"Text:\n{text}"
)
async def relay():
async with httpx.AsyncClient(timeout=300) as client:
async with client.stream(
"POST",
f"{OLLAMA_URL}/api/generate",
json={
"model": req.model,
"prompt": prompt,
"stream": True,
"options": {"temperature": 0.2},
},
) as r:
r.raise_for_status()
async for line in r.aiter_lines():
if line:
yield line + "\n"
return StreamingResponse(relay(), media_type="application/x-ndjson")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")