llmproxy/backend/admin.py
Oliver Hofmann 317c7f0340 Add Docker production build and update README
- Multi-stage Dockerfile: builds frontend, packages with Python backend
- admin.py serves frontend/dist as StaticFiles in production
- docker-entrypoint.sh runs proxy + admin-api, exits cleanly if either dies
- .dockerignore excludes .env, venv, tests, node_modules
- Split requirements.txt (prod) / requirements-dev.txt (dev+test)
- aiofiles added for StaticFiles support
- start.sh: port checks before startup, venv auto-activation, trap cleanup
- vite.config.js: clearScreen disabled
- README rewritten to reflect current architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:34:45 +02:00

129 lines
4.6 KiB
Python

import os
import secrets
import httpx
from pathlib import Path
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from database import get_db
import crud, schemas
from models import APIKey as APIKeyModel
app = FastAPI(title="Ollama Proxy Admin API")
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173").split(",")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
if not ADMIN_PASSWORD:
raise RuntimeError("ADMIN_PASSWORD environment variable must be set")
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
def require_admin_auth(request: Request):
auth = request.headers.get("Authorization", "")
token = auth.removeprefix("Bearer ").strip()
if not secrets.compare_digest(token, ADMIN_PASSWORD):
raise HTTPException(status_code=401, detail="Invalid admin password")
@app.get("/api/api-keys", response_model=list[schemas.APIKey])
async def read_api_keys(
skip: int = 0, limit: int = 100,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
return db.query(APIKeyModel).offset(skip).limit(limit).all()
@app.post("/api/api-keys", response_model=schemas.APIKeyCreated)
async def create_api_key(
api_key: schemas.APIKeyCreate,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
db_key, raw_key = crud.create_api_key(
db,
name=api_key.name,
expires_at=api_key.expires_at,
daily_tokens=api_key.daily_tokens,
monthly_tokens=api_key.monthly_tokens,
daily_requests=api_key.daily_requests,
monthly_requests=api_key.monthly_requests,
)
result = schemas.APIKeyCreated.model_validate(db_key)
result.plaintext_key = raw_key
return result
@app.patch("/api/api-keys/{api_key_id}/quota", response_model=schemas.APIKey)
async def update_quota(
api_key_id: int,
quota: schemas.QuotaUpdate,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
db_key = db.query(APIKeyModel).filter(APIKeyModel.id == api_key_id).first()
if not db_key:
raise HTTPException(status_code=404, detail="API key not found")
for field, value in quota.model_dump(exclude_unset=True).items():
setattr(db_key, field, value)
db.commit()
db.refresh(db_key)
return db_key
@app.put("/api/api-keys/{api_key_id}/deactivate")
async def deactivate_api_key(
api_key_id: int,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
db_key = db.query(APIKeyModel).filter(APIKeyModel.id == api_key_id).first()
if not db_key:
raise HTTPException(status_code=404, detail="API key not found")
db_key.is_active = False
db.commit()
return {"message": "API key deactivated"}
@app.get("/api/proxy-info")
async def get_proxy_info(_ = Depends(require_admin_auth)):
host = os.getenv("PROXY_HOST", "0.0.0.0")
port = os.getenv("PROXY_PORT", "8000")
display_host = "localhost" if host in ("0.0.0.0", "::") else host
return {"endpoint": f"http://{display_host}:{port}"}
@app.get("/api/settings", response_model=schemas.Settings)
async def read_settings(db: Session = Depends(get_db), _ = Depends(require_admin_auth)):
return schemas.Settings(
ollama_url=crud.get_setting(db, "ollama_url", "http://localhost:11434"),
default_model=crud.get_setting(db, "default_model", "llama3"),
)
@app.put("/api/settings", response_model=schemas.Settings)
async def update_settings(
settings: schemas.Settings,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
crud.set_setting(db, "ollama_url", settings.ollama_url)
crud.set_setting(db, "default_model", settings.default_model)
return settings
@app.get("/api/ollama-models")
async def get_ollama_models(db: Session = Depends(get_db), _ = Depends(require_admin_auth)):
ollama_url = crud.get_setting(db, "ollama_url", "http://localhost:11434")
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{ollama_url}/api/tags")
models = [m["name"] for m in response.json().get("models", [])]
except Exception:
models = []
return {"models": models}
# Statisches Frontend ausliefern (nur im Produktivbetrieb, wenn dist/ existiert)
_dist = Path(__file__).parent.parent / "frontend" / "dist"
if _dist.exists():
app.mount("/", StaticFiles(directory=_dist, html=True), name="frontend")