llmproxy/backend/admin.py
Oliver Hofmann c8235ec274 Refactor to flat APIKey model with quota, admin UI, .env config, and Berlin timezone
- Remove User/Quota models; quota fields now live directly on APIKey
- Admin UI: login, API key management, settings (Ollama URL/model), proxy info display
- .env/.env.example: ADMIN_PASSWORD, PROXY_HOST/PORT, DATABASE_URL, APP_TZ
- Admin API runs on 127.0.0.1 only; proxy host/port configurable
- API keys support optional expires_at; verified against Europe/Berlin timezone
- Daily/monthly quota resets use Europe/Berlin midnight boundary
- Fix all tests to use new flat model; add expiry tests

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

122 lines
4.3 KiB
Python

import os
import secrets
import httpx
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
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}