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>
171 lines
6.1 KiB
Python
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")
|