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:
parent
cfa874a4c3
commit
c8235ec274
18
.env.example
Normal file
18
.env.example
Normal 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
|
||||
142
backend/admin.py
142
backend/admin.py
@ -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}
|
||||
|
||||
125
backend/crud.py
125
backend/crud.py
@ -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
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
50
start.sh
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user