llmproxy/backend/admin.py
Oliver Hofmann dd8f69ecb6 Proxy fixes, streaming support, Admin-UI overhaul
Backend:
- Fix Content-Length mismatch by not forwarding client headers to Ollama
- Proxy /v1/chat/completions directly to Ollama's OpenAI-compatible endpoint
  (eliminates manual Ollama↔OpenAI format conversion, fixes tool use)
- Add streaming support via SSE passthrough
- Fix ollama_url /v1 suffix stripped on save
- Replace BaseHTTPMiddleware with FastAPI global dependency (fixes double logging)
- Add rotating usage log (8 KB, logs key name + model + token estimate + prompt preview)
- Add httpx timeout 300s
- Add activate and delete endpoints for API keys
- Return usage data (tokens/requests) in GET /api/api-keys

Frontend:
- Admin table: remove ID column, status as icon, icon-only action buttons with CSS tooltips
- Add activate + delete buttons; edit available for inactive keys too
- Quota columns: fixed equal width, progress bars with k-unit formatting
- Create form: structured layout matching edit form style
- Edit form: token inputs in k units (÷1000 display, ×1000 on save)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 07:48:10 +02:00

171 lines
6.1 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, Usage as UsageModel
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.APIKeyWithUsage])
async def read_api_keys(
skip: int = 0, limit: int = 100,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
keys = db.query(APIKeyModel).offset(skip).limit(limit).all()
result = []
for key in keys:
item = schemas.APIKeyWithUsage.model_validate(key)
usage = db.query(UsageModel).filter(UsageModel.api_key_id == key.id).first()
if usage:
item.tokens_used_today = usage.tokens_used_today or 0
item.tokens_used_month = usage.tokens_used_month or 0
item.requests_today = usage.requests_today or 0
item.requests_month = usage.requests_month or 0
result.append(item)
return result
@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.put("/api/api-keys/{api_key_id}/activate")
async def activate_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 = True
db.commit()
return {"message": "API key activated"}
@app.delete("/api/api-keys/{api_key_id}")
async def delete_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.delete(db_key)
db.commit()
return {"message": "API key deleted"}
@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),
):
ollama_url = settings.ollama_url.rstrip('/').removesuffix('/v1')
crud.set_setting(db, "ollama_url", ollama_url)
crud.set_setting(db, "default_model", settings.default_model)
return schemas.Settings(ollama_url=ollama_url, default_model=settings.default_model)
@app.get("/api/ollama-models")
async def get_ollama_models(
url: str = None,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
ollama_url = url or 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")