Includes earlier uncommitted work: Tomie and Unity adapters, benchmark, structured output, call metrics. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
165 lines
6.2 KiB
Python
165 lines
6.2 KiB
Python
from contextlib import asynccontextmanager
|
|
import asyncio
|
|
import logging
|
|
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from .config import Settings, load_settings
|
|
from .engine import Engine
|
|
from .models import Project, TranslationRequest
|
|
from .protection import BUILTIN_RULES
|
|
from .provider import Provider, ProviderError, autostart
|
|
from .storage import Storage
|
|
|
|
|
|
API_VERSION = 2
|
|
|
|
|
|
def create_app(settings: Settings | None = None, provider_factory=Provider):
|
|
settings = settings or load_settings()
|
|
storage = Storage(settings.database)
|
|
engine = Engine(storage, provider_factory)
|
|
startup_errors = {}
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
with storage.exclusive_worker():
|
|
for name, config in settings.providers.items():
|
|
if config.autostart:
|
|
try:
|
|
await autostart(config)
|
|
except ProviderError as exc:
|
|
startup_errors[name] = str(exc)
|
|
logging.getLogger(__name__).warning("%s: %s", name, exc)
|
|
engine.start()
|
|
try:
|
|
yield
|
|
finally:
|
|
await engine.stop()
|
|
|
|
app = FastAPI(title="Local Translator", version="0.1.0", lifespan=lifespan,
|
|
description="Local translation projects, glossary, durable jobs and protected placeholders.")
|
|
if settings.allowed_origins:
|
|
# allow_private_network answers Chrome's preflight for https page -> localhost.
|
|
app.add_middleware(CORSMiddleware, allow_origins=settings.allowed_origins, allow_methods=["GET", "POST", "PUT"],
|
|
allow_headers=["Content-Type"], expose_headers=["Location"], allow_private_network=True, max_age=600)
|
|
app.state.storage = storage
|
|
app.state.engine = engine
|
|
|
|
def project_or_404(identity):
|
|
project = storage.get_project(identity)
|
|
if not project:
|
|
raise HTTPException(404, "Project not found")
|
|
return project
|
|
|
|
def job_or_404(identity):
|
|
job = storage.get_job(identity)
|
|
if not job:
|
|
raise HTTPException(404, "Job not found")
|
|
return job
|
|
|
|
def validate_provider(project):
|
|
if project.provider not in settings.providers:
|
|
raise HTTPException(422, "Provider is not configured")
|
|
|
|
def enqueue(project_id, request):
|
|
project = project_or_404(project_id)
|
|
config = settings.providers.get(project["provider"])
|
|
if config is None:
|
|
raise HTTPException(422, "Provider is not configured")
|
|
result = storage.create_job(project, config.model_dump(), request.model_dump())
|
|
engine.wakeup.set()
|
|
return result
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok", "api_version": API_VERSION, "worker_running": engine.task is not None and not engine.task.done(), "startup_errors": startup_errors}
|
|
|
|
@app.get("/providers")
|
|
async def providers():
|
|
return {name: {"base_url": config.base_url, "model": config.model} for name, config in settings.providers.items()}
|
|
|
|
@app.get("/providers/{name}/models")
|
|
async def models(name: str):
|
|
config = settings.providers.get(name)
|
|
if config is None:
|
|
raise HTTPException(404, "Provider not found")
|
|
provider = provider_factory(config)
|
|
try:
|
|
return await provider.request("GET", "models")
|
|
except ProviderError as exc:
|
|
raise HTTPException(502, str(exc)) from exc
|
|
finally:
|
|
await provider.close()
|
|
|
|
@app.get("/protection-rules")
|
|
async def protection_rules():
|
|
return BUILTIN_RULES
|
|
|
|
@app.post("/projects", status_code=201)
|
|
async def create_project(project: Project):
|
|
validate_provider(project)
|
|
return storage.create_project(project.model_dump())
|
|
|
|
@app.get("/projects")
|
|
async def projects():
|
|
return storage.projects()
|
|
|
|
@app.get("/projects/{project_id}")
|
|
async def get_project(project_id: str):
|
|
return project_or_404(project_id)
|
|
|
|
@app.put("/projects/{project_id}")
|
|
async def replace_project(project_id: str, project: Project):
|
|
project_or_404(project_id)
|
|
validate_provider(project)
|
|
return storage.update_project(project_id, project.model_dump())
|
|
|
|
@app.post("/projects/{project_id}/jobs", status_code=202)
|
|
async def create_job(project_id: str, request: TranslationRequest):
|
|
return enqueue(project_id, request)
|
|
|
|
@app.post("/projects/{project_id}/translate")
|
|
async def translate(project_id: str, request: TranslationRequest):
|
|
job = enqueue(project_id, request)
|
|
# Bounded HTTP wait; the durable job continues after timeout/disconnect.
|
|
deadline = asyncio.get_running_loop().time() + 30
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
job = storage.get_job(job["id"])
|
|
if job["status"] not in {"queued", "running"}:
|
|
return job
|
|
await asyncio.sleep(0.1)
|
|
from fastapi.responses import JSONResponse
|
|
return JSONResponse(status_code=202, content=job, headers={"Location": f"/jobs/{job['id']}"})
|
|
|
|
@app.get("/projects/{project_id}/jobs")
|
|
async def list_jobs(project_id: str, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)):
|
|
project_or_404(project_id)
|
|
return storage.list_jobs(project_id, limit, offset)
|
|
|
|
@app.get("/jobs/{job_id}")
|
|
async def get_job(job_id: str):
|
|
return job_or_404(job_id)
|
|
|
|
@app.post("/jobs/{job_id}/retry", status_code=202)
|
|
async def retry_job(job_id: str):
|
|
job = job_or_404(job_id)
|
|
if job["status"] in {"queued", "running"}:
|
|
raise HTTPException(409, "Job is already queued or running")
|
|
if job["status"] == "completed":
|
|
raise HTTPException(409, "All items are already completed")
|
|
storage.retry(job_id)
|
|
engine.wakeup.set()
|
|
return storage.get_job(job_id)
|
|
|
|
@app.post("/jobs/{job_id}/cancel")
|
|
async def cancel_job(job_id: str):
|
|
job = job_or_404(job_id)
|
|
if job["status"] in {"queued", "running"}:
|
|
storage.cancel(job_id)
|
|
return storage.get_job(job_id)
|
|
return job
|
|
|
|
return app
|