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>
This commit is contained in:
Oliver Hofmann 2026-04-28 08:21:42 +02:00
parent cfa874a4c3
commit c8235ec274
15 changed files with 825 additions and 472 deletions

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
# Admin-Passwort für die Weboberfläche
ADMIN_PASSWORD=change-me
# Lokaler Endpunkt des Proxys (Admin-API bindet immer auf 127.0.0.1)
PROXY_HOST=0.0.0.0
PROXY_PORT=8000
ADMIN_PORT=8001
# Datenbankverbindung (Standard: SQLite für Entwicklung)
DATABASE_URL=sqlite:///./test.db
# Beispiel für PostgreSQL (Produktion):
# DATABASE_URL=postgresql://user:password@localhost:5432/llm_quota
# Ollama-Einstellungen (auch in der Admin-Oberfläche änderbar)
OLLAMA_URL=http://localhost:11434
DEFAULT_MODEL=llama3
APP_TZ=Europe/Berlin

View File

@ -1,115 +1,121 @@
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 User, APIKey, Quota
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", "DELETE"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
async def require_admin_auth(request: Request, db: Session = Depends(get_db)):
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
api_key = auth_header.replace("Bearer ", "")
elif auth_header.startswith("sk-"):
api_key = auth_header
else:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
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")
db_key = crud.verify_api_key(db, api_key)
if not db_key:
raise HTTPException(status_code=401, detail="Invalid API key")
db_user = db.query(User).filter(User.id == db_key.user_id).first()
if not db_user or not db_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
request.state.user = db_user
@app.get("/api/users", response_model=list[schemas.User])
async def read_users(
skip: int = 0,
limit: int = 100,
@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)
_ = Depends(require_admin_auth),
):
users = db.query(User).offset(skip).limit(limit).all()
return users
@app.post("/api/users", response_model=schemas.User)
async def create_user(
user: schemas.UserCreate,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth)
):
db_user = crud.get_user_by_username(db, username=user.username)
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
db_user = crud.get_user_by_email(db, email=user.email)
if db_user:
raise HTTPException(status_code=400, detail="Email already registered")
return crud.create_user(db=db, username=user.username, email=user.email, password=user.password)
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)
_ = Depends(require_admin_auth),
):
db_user = db.query(User).filter(User.id == api_key.user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
db_key, raw_key = crud.create_api_key(db=db, user_id=api_key.user_id, name=api_key.name)
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.get("/api/api-keys", response_model=list[schemas.APIKey])
async def read_api_keys(
skip: int = 0,
limit: int = 100,
@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)
_ = Depends(require_admin_auth),
):
api_keys = db.query(APIKey).offset(skip).limit(limit).all()
return api_keys
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)
_ = Depends(require_admin_auth),
):
db_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
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/quotas/{user_id}", response_model=schemas.Quota)
async def update_quota(
user_id: int,
quota: schemas.QuotaCreate,
@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)
_ = Depends(require_admin_auth),
):
db_quota = db.query(Quota).filter(Quota.user_id == user_id).first()
if not db_quota:
raise HTTPException(status_code=404, detail="Quota not found")
for key, value in quota.model_dump(exclude_unset=True).items():
setattr(db_quota, key, value)
db.commit()
db.refresh(db_quota)
return db_quota
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}

View File

@ -1,116 +1,115 @@
import os
import secrets
import hashlib
import bcrypt
import tiktoken
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from models import APIKey, User, Quota, Usage
from models import APIKey, Usage, Setting
_encoder = tiktoken.get_encoding("cl100k_base")
_tz = ZoneInfo(os.getenv("APP_TZ", "Europe/Berlin"))
def _now_local() -> datetime:
return datetime.now(_tz)
def _to_local(dt: datetime) -> datetime:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_tz)
def count_tokens(text: str) -> int:
return len(_encoder.encode(text))
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def _hash_api_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def get_user_by_username(db: Session, username: str):
return db.query(User).filter(User.username == username).first()
def get_user_by_email(db: Session, email: str):
return db.query(User).filter(User.email == email).first()
def generate_api_key():
def generate_api_key() -> str:
return "sk-" + secrets.token_urlsafe(32)
def create_user(db: Session, username: str, email: str, password: str, is_admin: bool = False):
db_user = User(
username=username,
email=email,
hashed_password=hash_password(password),
is_admin=is_admin,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
default_quota = Quota(
user_id=db_user.id,
daily_tokens=1000000,
monthly_tokens=10000000,
daily_requests=1000,
monthly_requests=10000
)
db.add(default_quota)
db.commit()
return db_user
def create_api_key(db: Session, user_id: int, name: str) -> tuple[APIKey, str]:
def create_api_key(
db: Session,
name: str,
expires_at: datetime = None,
daily_tokens: int = None,
monthly_tokens: int = None,
daily_requests: int = None,
monthly_requests: int = None,
) -> tuple[APIKey, str]:
raw_key = generate_api_key()
db_key = APIKey(
name=name,
key=_hash_api_key(raw_key),
user_id=user_id
expires_at=expires_at,
daily_tokens=daily_tokens,
monthly_tokens=monthly_tokens,
daily_requests=daily_requests,
monthly_requests=monthly_requests,
)
db.add(db_key)
db.commit()
db.refresh(db_key)
return db_key, raw_key
def get_setting(db: Session, key: str, default: str = None) -> str:
row = db.query(Setting).filter(Setting.key == key).first()
return row.value if row else default
def set_setting(db: Session, key: str, value: str) -> None:
row = db.query(Setting).filter(Setting.key == key).first()
if row:
row.value = value
else:
db.add(Setting(key=key, value=value))
db.commit()
def verify_api_key(db: Session, api_key: str):
key_hash = _hash_api_key(api_key)
return db.query(APIKey).filter(APIKey.key == key_hash, APIKey.is_active == True).first()
db_key = db.query(APIKey).filter(APIKey.key == key_hash, APIKey.is_active == True).first()
if db_key and db_key.expires_at:
expires = db_key.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires < datetime.now(timezone.utc):
return None
return db_key
def get_quota(db: Session, user_id: int):
return db.query(Quota).filter(Quota.user_id == user_id).first()
def get_quota_by_user_id(db: Session, user_id: int):
return db.query(Quota).filter(Quota.user_id == user_id).first()
def get_usage(db: Session, user_id: int):
return db.query(Usage).filter(Usage.user_id == user_id).first()
def check_and_increment_quota(db: Session, user_id: int, tokens: int = 0, requests: int = 1) -> bool:
def check_and_increment_quota(db: Session, api_key_id: int, tokens: int = 0, requests: int = 1) -> bool:
usage = (
db.query(Usage)
.filter(Usage.user_id == user_id)
.filter(Usage.api_key_id == api_key_id)
.with_for_update()
.first()
)
if not usage:
usage = Usage(user_id=user_id)
usage = Usage(api_key_id=api_key_id)
db.add(usage)
db.flush()
now = datetime.now(timezone.utc)
now = _now_local()
daily_reset_local = _to_local(usage.daily_reset_at)
monthly_reset_local = _to_local(usage.monthly_reset_at)
if usage.daily_reset_at.date() < now.date():
if daily_reset_local.date() < now.date():
usage.tokens_used_today = 0
usage.requests_today = 0
usage.daily_reset_at = now
if (usage.monthly_reset_at.year, usage.monthly_reset_at.month) < (now.year, now.month):
if (monthly_reset_local.year, monthly_reset_local.month) < (now.year, now.month):
usage.tokens_used_month = 0
usage.requests_month = 0
usage.monthly_reset_at = now
quota = get_quota(db, user_id)
api_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
allowed = True
if quota:
if quota.daily_tokens and (usage.tokens_used_today + tokens) > quota.daily_tokens:
if api_key:
if api_key.daily_tokens and (usage.tokens_used_today + tokens) > api_key.daily_tokens:
allowed = False
elif quota.monthly_tokens and (usage.tokens_used_month + tokens) > quota.monthly_tokens:
elif api_key.monthly_tokens and (usage.tokens_used_month + tokens) > api_key.monthly_tokens:
allowed = False
elif quota.daily_requests and (usage.requests_today + requests) > quota.daily_requests:
elif api_key.daily_requests and (usage.requests_today + requests) > api_key.daily_requests:
allowed = False
elif quota.monthly_requests and (usage.requests_month + requests) > quota.monthly_requests:
elif api_key.monthly_requests and (usage.requests_month + requests) > api_key.monthly_requests:
allowed = False
if allowed:
@ -120,4 +119,4 @@ def check_and_increment_quota(db: Session, user_id: int, tokens: int = 0, reques
usage.requests_month += requests
db.commit()
return allowed
return allowed

View File

@ -1,19 +1,18 @@
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
from sqlalchemy.orm import sessionmaker, declarative_base
try:
import os
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://ollama:password@localhost:5432/ollama_proxy")
if "sqlite" in DATABASE_URL:
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
else:
engine = create_engine(DATABASE_URL)
except Exception:
DATABASE_URL = "sqlite:///./test.db"
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./test.db")
if "sqlite" in DATABASE_URL:
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
else:
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():

View File

@ -1,24 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import Base
import models
import os
from dotenv import load_dotenv
SQLALCHEMY_DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./test.db")
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
try:
if "sqlite" in SQLALCHEMY_DATABASE_URL:
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
else:
engine = create_engine(SQLALCHEMY_DATABASE_URL)
except:
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
from database import Base, engine, SessionLocal
import models
from crud import get_setting, set_setting
def init_db():
Base.metadata.create_all(bind=engine)
print("Database tables created successfully!")
db = SessionLocal()
if not get_setting(db, "ollama_url"):
set_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
if not get_setting(db, "default_model"):
set_setting(db, "default_model", os.getenv("DEFAULT_MODEL", "llama3"))
db.close()
print("Database initialized.")
if __name__ == "__main__":
init_db()

View File

@ -9,7 +9,6 @@ import httpx
import os
app = FastAPI(title="Ollama Proxy")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
async def proxy_request(url: str, method: str = "GET", json_data: dict = None, headers: dict = None):
async with httpx.AsyncClient() as client:
@ -26,14 +25,13 @@ async def authenticate_and_quota(request: Request, call_next):
else:
return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"})
# Uses its own session since middleware cannot use Depends
db = SessionLocal()
try:
db_key = crud.verify_api_key(db, api_key)
if not db_key:
return JSONResponse(status_code=401, content={"detail": "Invalid API key"})
if not db_key.is_active:
return JSONResponse(status_code=403, content={"detail": "API key deactivated"})
request.state.user_id = db_key.user_id
request.state.api_key_id = db_key.id
finally:
db.close()
@ -42,45 +40,48 @@ async def authenticate_and_quota(request: Request, call_next):
@app.post("/api/generate")
async def generate(request: Request, db: Session = Depends(get_db)):
user_id = request.state.user_id
api_key_id = request.state.api_key_id
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
body = await request.json()
prompt_tokens = crud.count_tokens(body.get("prompt", ""))
if not crud.check_and_increment_quota(db, user_id, tokens=prompt_tokens, requests=1):
if not crud.check_and_increment_quota(db, api_key_id, tokens=prompt_tokens, requests=1):
raise HTTPException(status_code=429, detail="Quota exceeded")
response = await proxy_request(f"{OLLAMA_URL}/api/generate", method="POST", json_data=body, headers=dict(request.headers))
response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body, headers=dict(request.headers))
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
@app.post("/api/chat")
async def chat(request: Request, db: Session = Depends(get_db)):
user_id = request.state.user_id
api_key_id = request.state.api_key_id
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
body = await request.json()
prompt_tokens = sum(crud.count_tokens(msg.get("content", "")) for msg in body.get("messages", []))
if not crud.check_and_increment_quota(db, user_id, tokens=prompt_tokens, requests=1):
if not crud.check_and_increment_quota(db, api_key_id, tokens=prompt_tokens, requests=1):
raise HTTPException(status_code=429, detail="Quota exceeded")
response = await proxy_request(f"{OLLAMA_URL}/api/chat", method="POST", json_data=body, headers=dict(request.headers))
response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body, headers=dict(request.headers))
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
@app.get("/api/tags")
async def list_models(request: Request):
response = await proxy_request(f"{OLLAMA_URL}/api/tags", method="GET", headers=dict(request.headers))
async def list_models(request: Request, db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
response = await proxy_request(f"{ollama_url}/api/tags", method="GET", headers=dict(request.headers))
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
@app.get("/api/versions")
async def versions(request: Request):
response = await proxy_request(f"{OLLAMA_URL}/api/versions", method="GET", headers=dict(request.headers))
async def versions(request: Request, db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
response = await proxy_request(f"{ollama_url}/api/versions", method="GET", headers=dict(request.headers))
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
@app.get("/v1/models")
async def list_openai_models(request: Request):
response = await proxy_request(f"{OLLAMA_URL}/api/tags", method="GET", headers=dict(request.headers))
async def list_openai_models(request: Request, db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
response = await proxy_request(f"{ollama_url}/api/tags", method="GET", headers=dict(request.headers))
ollama_models = response.json()
openai_models = {
"object": "list",
@ -98,24 +99,24 @@ async def list_openai_models(request: Request):
@app.post("/v1/chat/completions")
async def openai_chat_completions(request: Request, db: Session = Depends(get_db)):
user_id = request.state.user_id
api_key_id = request.state.api_key_id
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
default_model = crud.get_setting(db, "default_model", os.getenv("DEFAULT_MODEL", "llama3"))
body = await request.json()
messages = body.get("messages", [])
prompt_tokens = sum(crud.count_tokens(msg.get("content", "")) for msg in messages)
if not crud.check_and_increment_quota(db, user_id, tokens=prompt_tokens, requests=1):
if not crud.check_and_increment_quota(db, api_key_id, tokens=prompt_tokens, requests=1):
raise HTTPException(status_code=429, detail="Quota exceeded")
ollama_body = {
"model": body.get("model", "llama3"),
"model": body.get("model", default_model),
"messages": messages,
"stream": body.get("stream", False)
}
response = await proxy_request(f"{OLLAMA_URL}/api/chat", method="POST", json_data=ollama_body, headers=dict(request.headers))
response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=ollama_body, headers=dict(request.headers))
response_content = response.json().get("message", {}).get("content", "")
completion_tokens = crud.count_tokens(response_content)
@ -123,22 +124,12 @@ async def openai_chat_completions(request: Request, db: Session = Depends(get_db
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": int(time.time()),
"model": body.get("model", "llama3"),
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": response_content
},
"finish_reason": "stop"
}
],
"model": body.get("model", default_model),
"choices": [{"index": 0, "message": {"role": "assistant", "content": response_content}, "finish_reason": "stop"}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens
}
"total_tokens": prompt_tokens + completion_tokens,
},
}
return JSONResponse(content=openai_response, status_code=200, headers={"Content-Type": "application/json"})

View File

@ -4,46 +4,34 @@ from database import Base
_now = lambda: datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=_now)
class APIKey(Base):
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
name = Column(String)
key = Column(String, unique=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), default=_now)
class Quota(Base):
__tablename__ = "quotas"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
expires_at = Column(DateTime(timezone=True), nullable=True)
daily_tokens = Column(BigInteger, nullable=True)
monthly_tokens = Column(BigInteger, nullable=True)
daily_requests = Column(Integer, nullable=True)
monthly_requests = Column(Integer, nullable=True)
reset_at = Column(DateTime(timezone=True), default=_now)
class Setting(Base):
__tablename__ = "settings"
key = Column(String, primary_key=True)
value = Column(String, nullable=False)
class Usage(Base):
__tablename__ = "usage"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), unique=True)
api_key_id = Column(Integer, ForeignKey("api_keys.id"), unique=True)
tokens_used_today = Column(BigInteger, default=0)
tokens_used_month = Column(BigInteger, default=0)
requests_today = Column(Integer, default=0)
requests_month = Column(Integer, default=0)
daily_reset_at = Column(DateTime(timezone=True), default=_now)
monthly_reset_at = Column(DateTime(timezone=True), default=_now)
monthly_reset_at = Column(DateTime(timezone=True), default=_now)

View File

@ -2,61 +2,45 @@ from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class UserBase(BaseModel):
username: str
email: str
is_admin: bool = False
class UserCreate(UserBase):
password: str
class User(UserBase):
id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class APIKeyBase(BaseModel):
class APIKeyCreate(BaseModel):
name: str
class APIKeyCreate(APIKeyBase):
user_id: int
class APIKey(APIKeyBase):
id: int
key: str
user_id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class APIKeyCreated(APIKey):
plaintext_key: str
class Config:
from_attributes = True
class QuotaBase(BaseModel):
expires_at: Optional[datetime] = None
daily_tokens: Optional[int] = None
monthly_tokens: Optional[int] = None
daily_requests: Optional[int] = None
monthly_requests: Optional[int] = None
class QuotaCreate(QuotaBase):
user_id: int
class Quota(QuotaBase):
class APIKey(BaseModel):
id: int
user_id: int
reset_at: Optional[datetime] = None
name: str
key: str
is_active: bool
created_at: datetime
expires_at: Optional[datetime] = None
daily_tokens: Optional[int] = None
monthly_tokens: Optional[int] = None
daily_requests: Optional[int] = None
monthly_requests: Optional[int] = None
class Config:
from_attributes = True
class APIKeyCreated(APIKey):
plaintext_key: Optional[str] = None
class Config:
from_attributes = True
class QuotaUpdate(BaseModel):
daily_tokens: Optional[int] = None
monthly_tokens: Optional[int] = None
daily_requests: Optional[int] = None
monthly_requests: Optional[int] = None
class Settings(BaseModel):
ollama_url: str
default_model: str
class UsageStats(BaseModel):
tokens_used_today: int = 0
tokens_used_month: int = 0
@ -66,4 +50,4 @@ class UsageStats(BaseModel):
monthly_reset_at: Optional[datetime] = None
class Config:
from_attributes = True
from_attributes = True

View File

@ -1,42 +1,25 @@
#!/usr/bin/env python3
from database import Base, engine, SessionLocal
from models import User, APIKey, Quota, Usage
from crud import create_user, create_api_key, hash_password
from models import APIKey
from crud import create_api_key
def setup_admin():
Base.metadata.create_all(bind=engine)
db = SessionLocal()
admin_user = db.query(User).filter(User.username == "admin").first()
if not admin_user:
admin_user = User(
username="admin",
email="admin@ollama.local",
hashed_password=hash_password("admin123"),
is_active=True,
is_admin=True,
existing = db.query(APIKey).filter(APIKey.name == "default").first()
if not existing:
_, raw_key = create_api_key(
db,
name="default",
daily_tokens=1_000_000,
monthly_tokens=10_000_000,
daily_requests=1000,
monthly_requests=10000,
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
print("✓ Admin user created")
default_quota = Quota(
user_id=admin_user.id,
daily_tokens=10000000,
monthly_tokens=100000000,
daily_requests=10000,
monthly_requests=100000
)
db.add(default_quota)
db.commit()
print("✓ Admin quota created")
_, raw_key = create_api_key(db, admin_user.id, "admin-api-key")
print(f"✓ Admin API Key: {raw_key}")
print(f"API Key created: {raw_key}")
else:
print("✗ Admin user already exists")
print("Default API key already exists")
db.close()
if __name__ == "__main__":
setup_admin()
setup_admin()

View File

@ -6,68 +6,23 @@ os.environ.setdefault("OLLAMA_URL", "http://127.0.0.1:9999")
def _setup_db():
from database import Base, engine, SessionLocal
from models import User, Quota
from crud import create_api_key, hash_password
from crud import create_api_key
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
test_user = User(
username="testuser",
email="test@example.com",
hashed_password=hash_password("test123"),
is_active=True,
)
db.add(test_user)
db.commit()
db.refresh(test_user)
db.add(Quota(
user_id=test_user.id,
daily_tokens=1_000_000,
monthly_tokens=10_000_000,
daily_requests=1000,
monthly_requests=10000,
))
db.commit()
_, raw_key = create_api_key(db, test_user.id, "test-key")
_, raw_key = create_api_key(db, name="test-key", daily_tokens=1_000_000,
monthly_tokens=10_000_000, daily_requests=1000,
monthly_requests=10000)
os.environ["TEST_API_KEY"] = raw_key
admin_user = User(
username="admin",
email="admin@example.com",
hashed_password=hash_password("admin123"),
is_active=True,
is_admin=True,
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
db.add(Quota(
user_id=admin_user.id,
daily_tokens=10_000_000,
monthly_tokens=100_000_000,
daily_requests=10000,
monthly_requests=100000,
))
db.commit()
_, admin_raw_key = create_api_key(db, admin_user.id, "admin-key")
os.environ["ADMIN_API_KEY"] = admin_raw_key
db.close()
def _teardown_db():
from database import engine
from models import Base
from database import engine, Base
Base.metadata.drop_all(bind=engine)
os.environ.pop("TEST_API_KEY", None)
os.environ.pop("ADMIN_API_KEY", None)
@pytest.fixture(scope="function")

View File

@ -5,33 +5,21 @@ from datetime import datetime, timedelta, timezone
os.environ.setdefault("OLLAMA_URL", "http://127.0.0.1:9999")
from database import Base, engine, SessionLocal
from models import User, Quota, Usage
from crud import check_and_increment_quota, count_tokens, hash_password
from models import APIKey, Usage
from crud import check_and_increment_quota, count_tokens, create_api_key, verify_api_key
def make_user_and_quota(db, daily_tokens=None, monthly_tokens=None,
daily_requests=None, monthly_requests=None):
user = User(
username="quotauser",
email="quota@example.com",
hashed_password=hash_password("pass"),
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
quota = Quota(
user_id=user.id,
def make_api_key(db, daily_tokens=None, monthly_tokens=None,
daily_requests=None, monthly_requests=None):
db_key, _ = create_api_key(
db,
name="test-key",
daily_tokens=daily_tokens,
monthly_tokens=monthly_tokens,
daily_requests=daily_requests,
monthly_requests=monthly_requests,
)
db.add(quota)
db.commit()
return user.id
return db_key.id
@pytest.fixture
@ -64,119 +52,133 @@ def test_count_tokens_more_accurate_than_split():
# --- check_and_increment_quota ---
def test_allowed_within_daily_token_limit(db):
user_id = make_user_and_quota(db, daily_tokens=1000)
assert check_and_increment_quota(db, user_id, tokens=100, requests=1) is True
api_key_id = make_api_key(db, daily_tokens=1000)
assert check_and_increment_quota(db, api_key_id, tokens=100, requests=1) is True
def test_denied_when_daily_tokens_exceeded(db):
user_id = make_user_and_quota(db, daily_tokens=50)
assert check_and_increment_quota(db, user_id, tokens=100, requests=1) is False
api_key_id = make_api_key(db, daily_tokens=50)
assert check_and_increment_quota(db, api_key_id, tokens=100, requests=1) is False
def test_denied_when_monthly_tokens_exceeded(db):
user_id = make_user_and_quota(db, monthly_tokens=50)
assert check_and_increment_quota(db, user_id, tokens=100, requests=1) is False
api_key_id = make_api_key(db, monthly_tokens=50)
assert check_and_increment_quota(db, api_key_id, tokens=100, requests=1) is False
def test_denied_when_daily_requests_exceeded(db):
user_id = make_user_and_quota(db, daily_requests=1)
check_and_increment_quota(db, user_id, tokens=0, requests=1)
assert check_and_increment_quota(db, user_id, tokens=0, requests=1) is False
api_key_id = make_api_key(db, daily_requests=1)
check_and_increment_quota(db, api_key_id, tokens=0, requests=1)
assert check_and_increment_quota(db, api_key_id, tokens=0, requests=1) is False
def test_denied_when_monthly_requests_exceeded(db):
user_id = make_user_and_quota(db, monthly_requests=1)
check_and_increment_quota(db, user_id, tokens=0, requests=1)
assert check_and_increment_quota(db, user_id, tokens=0, requests=1) is False
api_key_id = make_api_key(db, monthly_requests=1)
check_and_increment_quota(db, api_key_id, tokens=0, requests=1)
assert check_and_increment_quota(db, api_key_id, tokens=0, requests=1) is False
def test_increments_both_daily_and_monthly_counters(db):
user_id = make_user_and_quota(db, daily_tokens=1000, monthly_tokens=10000,
daily_requests=100, monthly_requests=1000)
check_and_increment_quota(db, user_id, tokens=50, requests=1)
api_key_id = make_api_key(db, daily_tokens=1000, monthly_tokens=10000,
daily_requests=100, monthly_requests=1000)
check_and_increment_quota(db, api_key_id, tokens=50, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
assert usage.tokens_used_today == 50
assert usage.tokens_used_month == 50
assert usage.requests_today == 1
assert usage.requests_month == 1
def test_creates_usage_record_on_first_call(db):
user_id = make_user_and_quota(db, daily_tokens=1000)
assert db.query(Usage).filter(Usage.user_id == user_id).first() is None
api_key_id = make_api_key(db, daily_tokens=1000)
assert db.query(Usage).filter(Usage.api_key_id == api_key_id).first() is None
check_and_increment_quota(db, user_id, tokens=10, requests=1)
check_and_increment_quota(db, api_key_id, tokens=10, requests=1)
assert db.query(Usage).filter(Usage.user_id == user_id).first() is not None
assert db.query(Usage).filter(Usage.api_key_id == api_key_id).first() is not None
def test_no_quota_allows_any_request(db):
user_id = make_user_and_quota(db) # all limits None
assert check_and_increment_quota(db, user_id, tokens=999999, requests=9999) is True
api_key_id = make_api_key(db) # all limits None
assert check_and_increment_quota(db, api_key_id, tokens=999999, requests=9999) is True
def test_cumulative_usage_across_calls(db):
user_id = make_user_and_quota(db, daily_tokens=200)
check_and_increment_quota(db, user_id, tokens=100, requests=1)
check_and_increment_quota(db, user_id, tokens=99, requests=1)
# 199 used, 1 remaining exactly 1 more token should pass
assert check_and_increment_quota(db, user_id, tokens=1, requests=1) is True
# Now 200 used next request must fail
assert check_and_increment_quota(db, user_id, tokens=1, requests=1) is False
api_key_id = make_api_key(db, daily_tokens=200)
check_and_increment_quota(db, api_key_id, tokens=100, requests=1)
check_and_increment_quota(db, api_key_id, tokens=99, requests=1)
assert check_and_increment_quota(db, api_key_id, tokens=1, requests=1) is True
assert check_and_increment_quota(db, api_key_id, tokens=1, requests=1) is False
# --- Reset logic ---
def test_daily_reset_restores_access(db):
user_id = make_user_and_quota(db, daily_tokens=100)
check_and_increment_quota(db, user_id, tokens=90, requests=1)
api_key_id = make_api_key(db, daily_tokens=100)
check_and_increment_quota(db, api_key_id, tokens=90, requests=1)
# Backdate daily_reset_at to yesterday
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1)
db.commit()
# Should pass again after reset
assert check_and_increment_quota(db, user_id, tokens=90, requests=1) is True
assert check_and_increment_quota(db, api_key_id, tokens=90, requests=1) is True
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
assert usage.tokens_used_today == 90
def test_daily_reset_does_not_affect_monthly_counter(db):
user_id = make_user_and_quota(db, daily_tokens=1000, monthly_tokens=10000)
check_and_increment_quota(db, user_id, tokens=50, requests=1)
api_key_id = make_api_key(db, daily_tokens=1000, monthly_tokens=10000)
check_and_increment_quota(db, api_key_id, tokens=50, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1)
db.commit()
check_and_increment_quota(db, user_id, tokens=50, requests=1)
check_and_increment_quota(db, api_key_id, tokens=50, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
assert usage.tokens_used_today == 50
assert usage.tokens_used_month == 100 # cumulative across days
assert usage.tokens_used_month == 100
def test_monthly_reset_restores_access(db):
user_id = make_user_and_quota(db, monthly_tokens=100)
check_and_increment_quota(db, user_id, tokens=90, requests=1)
api_key_id = make_api_key(db, monthly_tokens=100)
check_and_increment_quota(db, api_key_id, tokens=90, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
usage.monthly_reset_at = datetime.now(timezone.utc) - timedelta(days=32)
db.commit()
assert check_and_increment_quota(db, user_id, tokens=90, requests=1) is True
assert check_and_increment_quota(db, api_key_id, tokens=90, requests=1) is True
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
assert usage.tokens_used_month == 90
def test_failed_quota_check_still_commits_reset(db):
user_id = make_user_and_quota(db, daily_tokens=100, daily_requests=5)
check_and_increment_quota(db, user_id, tokens=80, requests=1)
api_key_id = make_api_key(db, daily_tokens=100, daily_requests=5)
check_and_increment_quota(db, api_key_id, tokens=80, requests=1)
# Backdate so a reset fires, but the new request still exceeds the limit
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1)
usage.tokens_used_today = 80
db.commit()
# After reset tokens_used_today = 0; 200 tokens exceeds 100 limit
result = check_and_increment_quota(db, user_id, tokens=200, requests=1)
result = check_and_increment_quota(db, api_key_id, tokens=200, requests=1)
assert result is False
# Reset must still be persisted so the next request sees fresh counters
db.expire_all()
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
assert usage.tokens_used_today == 0
# --- verify_api_key expiry ---
def _create_raw_key(db, expires_at=None):
_, raw_key = create_api_key(db, name="expiry-test", expires_at=expires_at)
return raw_key
def test_key_without_expiry_is_valid(db):
raw_key = _create_raw_key(db)
assert verify_api_key(db, raw_key) is not None
def test_key_with_future_expiry_is_valid(db):
future = datetime.now(timezone.utc) + timedelta(days=30)
raw_key = _create_raw_key(db, expires_at=future)
assert verify_api_key(db, raw_key) is not None
def test_key_with_past_expiry_is_rejected(db):
past = datetime.now(timezone.utc) - timedelta(seconds=1)
raw_key = _create_raw_key(db, expires_at=past)
assert verify_api_key(db, raw_key) is None

View File

@ -1,66 +1,256 @@
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom/client';
import axios from 'axios';
import './styles.css';
const maskKey = (key) => `••••••••${key.slice(-4)}`;
function App() {
const [users, setUsers] = useState([]);
const [apiKeys, setApiKeys] = useState([]);
const [loading, setLoading] = useState(true);
function authHeaders(token) {
return { Authorization: `Bearer ${token}` };
}
function Login({ onLogin }) {
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
useEffect(() => {
Promise.all([fetchUsers(), fetchApiKeys()]).finally(() => setLoading(false));
}, []);
const fetchUsers = async () => {
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
try {
const res = await axios.get('/api/users');
setUsers(res.data);
} catch (err) {
setError('Benutzer konnten nicht geladen werden.');
await axios.get('/api/api-keys', { headers: authHeaders(password) });
sessionStorage.setItem('admin_password', password);
onLogin(password);
} catch {
setError('Ungültiges Passwort.');
}
};
const fetchApiKeys = async () => {
try {
const res = await axios.get('/api/api-keys');
setApiKeys(res.data);
} catch (err) {
setError('API-Keys konnten nicht geladen werden.');
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="container">
<h1>Ollama Proxy Admin</h1>
<form onSubmit={handleSubmit} className="login-form">
<label>Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Admin-Passwort eingeben"
autoFocus
/>
{error && <div className="error">{error}</div>}
<button type="submit">Anmelden</button>
</form>
</div>
);
}
const EMPTY_KEY_FORM = {
name: '', expires_at: '', daily_tokens: '', monthly_tokens: '', daily_requests: '', monthly_requests: '',
};
function SettingsSection({ password }) {
const [settings, setSettings] = useState(null);
const [availableModels, setAvailableModels] = useState([]);
const [proxyEndpoint, setProxyEndpoint] = useState(null);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const headers = authHeaders(password);
Promise.all([
axios.get('/api/settings', { headers }),
axios.get('/api/ollama-models', { headers }),
axios.get('/api/proxy-info', { headers }),
]).then(([settingsRes, modelsRes, proxyRes]) => {
setSettings(settingsRes.data);
setAvailableModels(modelsRes.data.models);
setProxyEndpoint(proxyRes.data.endpoint);
}).catch(() => setError('Einstellungen konnten nicht geladen werden.'));
}, []);
const handleSave = async (e) => {
e.preventDefault();
setError(null);
setSaved(false);
try {
await axios.put('/api/settings', settings, { headers: authHeaders(password) });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch {
setError('Fehler beim Speichern.');
}
};
if (!settings) return <div>Laden...</div>;
return (
<section>
<h2>Einstellungen</h2>
<form onSubmit={handleSave} className="settings-form">
<div className="settings-row">
<label>Proxy-Endpunkt</label>
<span className="settings-value">
{proxyEndpoint ?? '…'}
<small> (Änderung erfordert Neustart)</small>
</span>
</div>
<div className="settings-row">
<label>Ollama-Endpunkt</label>
<input
type="url"
value={settings.ollama_url}
onChange={(e) => setSettings({ ...settings, ollama_url: e.target.value })}
placeholder="http://localhost:11434"
required
/>
</div>
<div className="settings-row">
<label>Standard-Modell</label>
{availableModels.length > 0 ? (
<select
value={settings.default_model}
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })}
>
{availableModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
) : (
<input
type="text"
value={settings.default_model}
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })}
placeholder="llama3"
required
/>
)}
</div>
{error && <div className="error">{error}</div>}
{saved && <div className="success">Gespeichert.</div>}
<button type="submit">Speichern</button>
</form>
</section>
);
}
function App() {
const [password, setPassword] = useState(() => sessionStorage.getItem('admin_password'));
const [apiKeys, setApiKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [newKey, setNewKey] = useState(null);
const [form, setForm] = useState(EMPTY_KEY_FORM);
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!password) { setLoading(false); return; }
fetchApiKeys().finally(() => setLoading(false));
}, [password]);
const fetchApiKeys = async () => {
try {
const res = await axios.get('/api/api-keys', { headers: authHeaders(password) });
setApiKeys(res.data);
} catch {
setError('API-Keys konnten nicht geladen werden.');
}
};
const handleCreate = async (e) => {
e.preventDefault();
setCreating(true);
try {
const payload = { name: form.name };
if (form.expires_at) payload.expires_at = new Date(form.expires_at).toISOString();
if (form.daily_tokens) payload.daily_tokens = Number(form.daily_tokens);
if (form.monthly_tokens) payload.monthly_tokens = Number(form.monthly_tokens);
if (form.daily_requests) payload.daily_requests = Number(form.daily_requests);
if (form.monthly_requests) payload.monthly_requests = Number(form.monthly_requests);
const res = await axios.post('/api/api-keys', payload, { headers: authHeaders(password) });
setNewKey(res.data.plaintext_key);
setForm(EMPTY_KEY_FORM);
await fetchApiKeys();
} catch {
setError('Fehler beim Erstellen des API-Keys.');
} finally {
setCreating(false);
}
};
const handleDeactivate = async (id) => {
try {
await axios.put(`/api/api-keys/${id}/deactivate`, {}, { headers: authHeaders(password) });
await fetchApiKeys();
} catch {
setError('Fehler beim Deaktivieren.');
}
};
const logout = () => {
sessionStorage.removeItem('admin_password');
setPassword(null);
};
if (!password) return <Login onLogin={setPassword} />;
if (loading) return <div>Laden...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="container">
<div className="header">
<h1>Ollama Proxy Admin</h1>
<button onClick={logout}>Abmelden</button>
</div>
<SettingsSection password={password} />
<section>
<h2>Users</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.username}</td>
<td>{user.email}</td>
<td>{user.is_active ? 'Active' : 'Inactive'}</td>
</tr>
))}
</tbody>
</table>
<h2>Neuer API-Key</h2>
<form onSubmit={handleCreate} className="create-form">
<input
placeholder="Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
<input
type="date"
placeholder="Ablaufdatum (leer = unbegrenzt)"
value={form.expires_at}
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
/>
<input
type="number"
placeholder="Tokens/Tag (leer = unbegrenzt)"
value={form.daily_tokens}
onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })}
/>
<input
type="number"
placeholder="Tokens/Monat"
value={form.monthly_tokens}
onChange={(e) => setForm({ ...form, monthly_tokens: e.target.value })}
/>
<input
type="number"
placeholder="Requests/Tag"
value={form.daily_requests}
onChange={(e) => setForm({ ...form, daily_requests: e.target.value })}
/>
<input
type="number"
placeholder="Requests/Monat"
value={form.monthly_requests}
onChange={(e) => setForm({ ...form, monthly_requests: e.target.value })}
/>
<button type="submit" disabled={creating}>Erstellen</button>
</form>
{newKey && (
<div className="new-key-box">
<strong>Neuer Key (nur einmal sichtbar):</strong>
<code>{newKey}</code>
<button onClick={() => setNewKey(null)}></button>
</div>
)}
</section>
<section>
@ -71,8 +261,13 @@ function App() {
<th>ID</th>
<th>Name</th>
<th>Key</th>
<th>User</th>
<th>Status</th>
<th>Läuft ab</th>
<th>Tokens/Tag</th>
<th>Tokens/Monat</th>
<th>Req/Tag</th>
<th>Req/Monat</th>
<th></th>
</tr>
</thead>
<tbody>
@ -81,8 +276,19 @@ function App() {
<td>{key.id}</td>
<td>{key.name}</td>
<td>{maskKey(key.key)}</td>
<td>{key.user_id}</td>
<td>{key.is_active ? 'Active' : 'Inactive'}</td>
<td>{key.is_active ? 'Aktiv' : 'Inaktiv'}</td>
<td>{key.expires_at ? new Date(key.expires_at).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '∞'}</td>
<td>{key.daily_tokens ?? '∞'}</td>
<td>{key.monthly_tokens ?? '∞'}</td>
<td>{key.daily_requests ?? '∞'}</td>
<td>{key.monthly_requests ?? '∞'}</td>
<td>
{key.is_active && (
<button className="btn-danger" onClick={() => handleDeactivate(key.id)}>
Deaktivieren
</button>
)}
</td>
</tr>
))}
</tbody>
@ -92,4 +298,8 @@ function App() {
);
}
export default App;
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -44,12 +44,177 @@ tr:hover {
background: #f8f9fa;
}
.status-active {
color: #27ae60;
font-weight: 600;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
}
.status-inactive {
color: #e74c3c;
font-weight: 600;
.header h1 {
margin-bottom: 0;
}
.login-form {
max-width: 360px;
margin: 80px auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.login-form input {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.login-form button, .header button {
padding: 10px 20px;
background: #2c3e50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.login-form button:hover, .header button:hover {
background: #34495e;
}
.error {
color: #e74c3c;
font-size: 14px;
}
.create-form {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
}
.create-form input {
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
flex: 1 1 160px;
}
.create-form button {
padding: 8px 20px;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.create-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.new-key-box {
margin-top: 16px;
padding: 12px 16px;
background: #eafaf1;
border: 1px solid #27ae60;
border-radius: 4px;
display: flex;
align-items: center;
gap: 12px;
}
.new-key-box code {
flex: 1;
font-size: 13px;
word-break: break-all;
}
.new-key-box button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #555;
}
.btn-danger {
padding: 4px 10px;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.btn-danger:hover {
background: #c0392b;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 500px;
}
.settings-row {
display: flex;
align-items: center;
gap: 12px;
}
.settings-row label {
width: 160px;
flex-shrink: 0;
font-weight: 500;
color: #2c3e50;
}
.settings-row input, .settings-row select {
flex: 1;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.settings-form button {
align-self: flex-start;
padding: 8px 20px;
background: #2c3e50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.settings-form button:hover {
background: #34495e;
}
.success {
color: #27ae60;
font-size: 14px;
}
.settings-value {
flex: 1;
font-size: 14px;
color: #2c3e50;
}
.settings-value small {
margin-left: 8px;
color: #999;
font-size: 12px;
}

View File

@ -5,6 +5,10 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api/api-keys': 'http://localhost:8001',
'/api/settings': 'http://localhost:8001',
'/api/ollama-models': 'http://localhost:8001',
'/api/proxy-info': 'http://localhost:8001',
'/api': 'http://localhost:8000',
},
},

50
start.sh Executable file
View File

@ -0,0 +1,50 @@
#!/bin/bash
# .env laden
if [ -f .env ]; then
set -a
source .env
set +a
fi
if [ -z "$ADMIN_PASSWORD" ]; then
echo "Fehler: ADMIN_PASSWORD ist nicht gesetzt. Bitte .env befüllen."
exit 1
fi
# Datenbank initialisieren
echo "Initialisiere Datenbank..."
cd backend
python3 init_db.py
cd ..
PROXY_HOST=${PROXY_HOST:-0.0.0.0}
PROXY_PORT=${PROXY_PORT:-8000}
ADMIN_PORT=${ADMIN_PORT:-8001}
# Backend starten
echo "Starte Backend (Proxy) auf ${PROXY_HOST}:${PROXY_PORT}..."
cd backend
python3 -m uvicorn main:app --reload --host "$PROXY_HOST" --port "$PROXY_PORT" &
BACKEND_PID=$!
# Admin-API immer nur lokal erreichbar (Host nicht konfigurierbar)
echo "Starte Admin-API auf 127.0.0.1:${ADMIN_PORT}..."
python3 -m uvicorn admin:app --reload --host 127.0.0.1 --port "$ADMIN_PORT" &
ADMIN_PID=$!
cd ..
# Frontend starten
echo "Starte Frontend..."
cd frontend
npm install --silent
npm run dev &
FRONTEND_PID=$!
cd ..
echo "Backend läuft auf PID: $BACKEND_PID (Port $PROXY_PORT)"
echo "Admin-API läuft auf PID: $ADMIN_PID (Port 8001, nur lokal)"
echo "Frontend läuft auf PID: $FRONTEND_PID"
echo "Admin-Oberfläche: http://localhost:5173"
wait