diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7c3d6c7 --- /dev/null +++ b/.env.example @@ -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 diff --git a/backend/admin.py b/backend/admin.py index 9bb53f8..c7d201f 100644 --- a/backend/admin.py +++ b/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} diff --git a/backend/crud.py b/backend/crud.py index c07185d..36b96f8 100644 --- a/backend/crud.py +++ b/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 \ No newline at end of file + return allowed diff --git a/backend/database.py b/backend/database.py index ea74e10..df9ad23 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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(): diff --git a/backend/init_db.py b/backend/init_db.py index a946e3d..a45e257 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -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() diff --git a/backend/main.py b/backend/main.py index 5a458d5..aa39081 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"}) diff --git a/backend/models.py b/backend/models.py index 1d2d1c6..8a0b8ac 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) \ No newline at end of file diff --git a/backend/schemas.py b/backend/schemas.py index cc3cd1d..80edc38 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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 \ No newline at end of file diff --git a/backend/setup_admin.py b/backend/setup_admin.py index 6ad72f1..f20d153 100644 --- a/backend/setup_admin.py +++ b/backend/setup_admin.py @@ -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() \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1a3c04f..156d982 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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") diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index a6d4d8b..e05d1e2 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -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 diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 1f5d570..a8456d7 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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
| ID | -Username | -Status | -|
|---|---|---|---|
| {user.id} | -{user.username} | -{user.email} | -{user.is_active ? 'Active' : 'Inactive'} | -
{newKey}
+
+