From c8235ec274ddbe54118dfea275730653bb29ccbe Mon Sep 17 00:00:00 2001 From: Oliver Hofmann Date: Tue, 28 Apr 2026 08:21:42 +0200 Subject: [PATCH] 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 --- .env.example | 18 +++ backend/admin.py | 142 +++++++++-------- backend/crud.py | 125 ++++++++------- backend/database.py | 19 ++- backend/init_db.py | 29 ++-- backend/main.py | 67 ++++---- backend/models.py | 30 ++-- backend/schemas.py | 74 ++++----- backend/setup_admin.py | 45 ++---- backend/tests/conftest.py | 55 +------ backend/tests/test_quota.py | 152 +++++++++--------- frontend/src/main.jsx | 310 ++++++++++++++++++++++++++++++------ frontend/src/styles.css | 177 +++++++++++++++++++- frontend/vite.config.js | 4 + start.sh | 50 ++++++ 15 files changed, 825 insertions(+), 472 deletions(-) create mode 100644 .env.example create mode 100755 start.sh 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
Loading...
; - if (error) return
{error}
; - return (

Ollama Proxy Admin

+
+ + setPassword(e.target.value)} + placeholder="Admin-Passwort eingeben" + autoFocus + /> + {error &&
{error}
} + +
+
+ ); +} + +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
Laden...
; + + return ( +
+

Einstellungen

+
+
+ + + {proxyEndpoint ?? '…'} + (Änderung erfordert Neustart) + +
+
+ + setSettings({ ...settings, ollama_url: e.target.value })} + placeholder="http://localhost:11434" + required + /> +
+
+ + {availableModels.length > 0 ? ( + + ) : ( + setSettings({ ...settings, default_model: e.target.value })} + placeholder="llama3" + required + /> + )} +
+ {error &&
{error}
} + {saved &&
Gespeichert.
} + +
+
+ ); +} + +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 ; + if (loading) return
Laden...
; + if (error) return
{error}
; + + return ( +
+
+

Ollama Proxy Admin

+ +
+ +
-

Users

- - - - - - - - - - - {users.map(user => ( - - - - - - - ))} - -
IDUsernameEmailStatus
{user.id}{user.username}{user.email}{user.is_active ? 'Active' : 'Inactive'}
+

Neuer API-Key

+
+ setForm({ ...form, name: e.target.value })} + required + /> + setForm({ ...form, expires_at: e.target.value })} + /> + setForm({ ...form, daily_tokens: e.target.value })} + /> + setForm({ ...form, monthly_tokens: e.target.value })} + /> + setForm({ ...form, daily_requests: e.target.value })} + /> + setForm({ ...form, monthly_requests: e.target.value })} + /> + +
+ {newKey && ( +
+ Neuer Key (nur einmal sichtbar): + {newKey} + +
+ )}
@@ -71,8 +261,13 @@ function App() { ID Name Key - User Status + Läuft ab + Tokens/Tag + Tokens/Monat + Req/Tag + Req/Monat + @@ -81,8 +276,19 @@ function App() { {key.id} {key.name} {maskKey(key.key)} - {key.user_id} - {key.is_active ? 'Active' : 'Inactive'} + {key.is_active ? 'Aktiv' : 'Inaktiv'} + {key.expires_at ? new Date(key.expires_at).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '∞'} + {key.daily_tokens ?? '∞'} + {key.monthly_tokens ?? '∞'} + {key.daily_requests ?? '∞'} + {key.monthly_requests ?? '∞'} + + {key.is_active && ( + + )} + ))} @@ -92,4 +298,8 @@ function App() { ); } -export default App; \ No newline at end of file +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6e32e06..4aad174 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 69163ad..dc2bc36 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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', }, }, diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..8f19531 --- /dev/null +++ b/start.sh @@ -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