Compare commits

..

No commits in common. "317c7f03408ae745eca94221ce08ed176a229ecd" and "cfa874a4c3ec1f57e824d262aa8b65253f07079c" have entirely different histories.

21 changed files with 557 additions and 1038 deletions

View File

@ -1,11 +0,0 @@
.git/
.venv/
venv/
.env
frontend/node_modules/
frontend/dist/
backend/__pycache__/
backend/**/__pycache__/
backend/*.pyc
backend/test.db
backend/tests/

View File

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

View File

@ -1,22 +0,0 @@
FROM node:20-alpine AS frontend-builder
WORKDIR /app
COPY frontend/package*.json frontend/
RUN npm ci --prefix frontend
COPY frontend/ frontend/
RUN npm run build --prefix frontend
FROM python:3.12-slim
WORKDIR /app
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ backend/
COPY --from=frontend-builder /app/frontend/dist frontend/dist
COPY docker-entrypoint.sh .
RUN chmod +x docker-entrypoint.sh
EXPOSE 8000 8001
CMD ["./docker-entrypoint.sh"]

231
README.md
View File

@ -1,153 +1,122 @@
# Ollama Proxy mit API-Keys und Quotas # Ollama Proxy mit API-Keys und Quotas
Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung, Quota-Management und Web-Admin-Oberfläche. Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung und Quota-Management.
## Features ## Features
- API-Key-Authentifizierung (Bearer Token oder `sk-`-Prefix) - API-Key-Authentifizierung (Bearer Token oder `sk-`-Prefix)
- Optionales Ablaufdatum pro API-Key
- Quota-Management mit getrennten Tages- und Monatslimits (Tokens & Requests) - Quota-Management mit getrennten Tages- und Monatslimits (Tokens & Requests)
- Token-Zählung via tiktoken, Reset-Grenzen in der Zeitzone Europe/Berlin - Token-Zählung via tiktoken (cl100k_base)
- Web-Admin-Oberfläche (API-Keys verwalten, Ollama-Einstellungen, Proxy-Info) - Usage-Tracking mit automatischem täglichem/monatlichem Reset
- Web-Admin-Oberfläche für User- und Quota-Verwaltung
- OpenAI-kompatibler `/v1/chat/completions`-Endpunkt - OpenAI-kompatibler `/v1/chat/completions`-Endpunkt
## Sicherheit ## Sicherheit
- Admin-Oberfläche passwortgeschützt (`ADMIN_PASSWORD`) - Passwörter mit bcrypt gehasht
- Admin-API bindet lokal auf `127.0.0.1` (nicht von außen erreichbar) - API-Keys als SHA-256-Hash in der DB Plaintext wird nur einmalig bei Erstellung zurückgegeben
- API-Keys als SHA-256-Hash in der DB — Plaintext nur einmalig bei Erstellung - Admin-Zugriff über `is_admin`-Flag in der DB, nicht über Hardcoded-Namen
- Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race)
- CORS-Origins konfigurierbar via `ALLOWED_ORIGINS` - CORS-Origins konfigurierbar via `ALLOWED_ORIGINS`
- Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race)
## Installation & Start
### Voraussetzungen
- Python 3.12+
- PostgreSQL 16+ (oder SQLite für Entwicklung)
- Node.js 18+ (für Frontend)
### Lokal mit SQLite (Entwicklung)
```bash
# Backend
cd backend
pip install -r requirements.txt
python init_db.py
python setup_admin.py
uvicorn main:app --reload --port 8000
# Admin-API (in neuem Terminal)
uvicorn admin:app --reload --port 8001
# Frontend (in neuem Terminal)
cd frontend
npm install
npm run dev
```
### Mit PostgreSQL & Docker (Produktion)
```bash
docker compose up -d
docker compose exec backend python init_db.py
docker compose exec backend python setup_admin.py
```
## Konfiguration ## Konfiguration
`.env`-Datei im Projektverzeichnis anlegen (Vorlage: `.env.example`): `.env`-Datei im `backend/`-Verzeichnis anlegen:
```env ```env
ADMIN_PASSWORD=change-me DATABASE_URL=postgresql://user:pass@host:5432/db
PROXY_HOST=0.0.0.0 OLLAMA_URL=http://ollama:11434
PROXY_PORT=8000 ALLOWED_ORIGINS=https://admin.example.com
ADMIN_PORT=8001
DATABASE_URL=sqlite:///./test.db
OLLAMA_URL=http://localhost:11434
DEFAULT_MODEL=llama3
APP_TZ=Europe/Berlin
``` ```
| Variable | Standard | Beschreibung | | Variable | Standard | Beschreibung |
|----------|----------|--------------| |----------|----------|--------------|
| `ADMIN_PASSWORD` | — | Passwort für die Admin-Oberfläche (**Pflicht**) | | `DATABASE_URL` | PostgreSQL lokal | DB-Verbindungsstring; `sqlite:///` für SQLite |
| `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxys | | `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz |
| `PROXY_PORT` | `8000` | Port des Proxys | | `ALLOWED_ORIGINS` | `http://localhost:5173` | Kommagetrennte CORS-Origins für die Admin-UI |
| `ADMIN_PORT` | `8001` | Port der Admin-API |
| `DATABASE_URL` | `sqlite:///./test.db` | DB-Verbindungsstring |
| `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz (auch in der UI änderbar) |
| `DEFAULT_MODEL` | `llama3` | Standard-Modell für `/v1/chat/completions` (auch in der UI änderbar) |
| `APP_TZ` | `Europe/Berlin` | Zeitzone für tägliche/monatliche Quota-Resets |
| `ALLOWED_ORIGINS` | `http://localhost:5173` | Kommagetrennte CORS-Origins |
## Entwicklung (lokal) ## Proxy-Endpunkte
```bash
cp .env.example .env
# ADMIN_PASSWORD in .env setzen
./start.sh
```
Das Script prüft alle Ports auf Belegung, aktiviert automatisch eine vorhandene `.venv`, initialisiert die Datenbank und startet Proxy, Admin-API und Vite-Dev-Server.
Admin-Oberfläche: `http://localhost:5173`
### Voraussetzungen
- Python 3.12+ mit virtualenv
- Node.js 18+
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r backend/requirements-dev.txt
cd frontend && npm install
```
## Produktion (Docker)
### Image bauen
```bash
docker build -t llm-quota .
```
### Container starten
```bash
docker run -d \
-p 8000:8000 \
-p 8001:8001 \
-e ADMIN_PASSWORD=geheim \
-e OLLAMA_URL=http://host.docker.internal:11434 \
-e DATABASE_URL=sqlite:///./data/quota.db \
-v $(pwd)/data:/app/backend/data \
--name llm-quota \
llm-quota
```
| Port | Dienst |
|------|--------|
| `8000` | Proxy (für LLM-Clients) |
| `8001` | Admin-API + Admin-Oberfläche |
Admin-Oberfläche: `http://localhost:8001`
### Mit PostgreSQL
```bash
docker run -d \
-p 8000:8000 \
-p 8001:8001 \
-e ADMIN_PASSWORD=geheim \
-e DATABASE_URL=postgresql://user:pass@db-host:5432/llm_quota \
-e OLLAMA_URL=http://ollama:11434 \
llm-quota
```
> **Hinweis:** Im Container bindet die Admin-API auf `0.0.0.0`. Port 8001 sollte nicht öffentlich exponiert werden — entweder per Firewall absichern oder hinter einem Reverse-Proxy (nginx, Caddy) betreiben.
## Proxy-Endpunkte (Port 8000)
Alle Endpunkte erfordern einen gültigen API-Key im `Authorization`-Header. Alle Endpunkte erfordern einen gültigen API-Key im `Authorization`-Header.
```bash ```bash
curl -X POST http://localhost:8000/api/chat \ curl -X POST http://localhost:8000/api/generate \
-H "Authorization: Bearer sk-xxxxxx" \ -H "Authorization: Bearer sk-xxxxxx" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"model":"llama3","messages":[{"role":"user","content":"Hallo"}]}' -d '{"model":"llama3","prompt":"Say hello"}'
``` ```
| Endpunkt | Methode | Beschreibung | | Endpunkt | Beschreibung |
|----------|---------|--------------| |----------|--------------|
| `/api/generate` | POST | Ollama generate | | `POST /api/generate` | Ollama generate |
| `/api/chat` | POST | Ollama chat | | `POST /api/chat` | Ollama chat |
| `/api/tags` | GET | Verfügbare Modelle | | `GET /api/tags` | Verfügbare Modelle |
| `/api/versions` | GET | Ollama-Version | | `GET /v1/models` | Modelle (OpenAI-Format) |
| `/v1/models` | GET | Modelle (OpenAI-Format) | | `POST /v1/chat/completions` | Chat (OpenAI-Format) |
| `/v1/chat/completions` | POST | Chat (OpenAI-Format) |
## Admin-API (Port 8001) ## Admin-API (Port 8001)
Alle Endpunkte erfordern `Authorization: Bearer <ADMIN_PASSWORD>`. Alle Endpunkte erfordern einen API-Key eines Nutzers mit `is_admin=true`.
| Endpunkt | Methode | Beschreibung | | Endpunkt | Methode | Beschreibung |
|----------|---------|--------------| |----------|---------|--------------|
| `/api/users` | GET | Alle User auflisten |
| `/api/users` | POST | Neuen User anlegen |
| `/api/api-keys` | GET | Alle API-Keys auflisten | | `/api/api-keys` | GET | Alle API-Keys auflisten |
| `/api/api-keys` | POST | Neuen API-Key erstellen | | `/api/api-keys` | POST | Neuen API-Key erstellen (Plaintext einmalig in Response) |
| `/api/api-keys/{id}/deactivate` | PUT | API-Key deaktivieren | | `/api/api-keys/{id}/deactivate` | PUT | API-Key deaktivieren |
| `/api/api-keys/{id}/quota` | PATCH | Quota eines Keys aktualisieren | | `/api/quotas/{user_id}` | PUT | Quota für User setzen |
| `/api/settings` | GET/PUT | Ollama-URL und Standard-Modell |
| `/api/ollama-models` | GET | Verfügbare Modelle von Ollama | ### Quota setzen
| `/api/proxy-info` | GET | Lokaler Proxy-Endpunkt |
```bash
curl -X PUT http://localhost:8001/api/quotas/1 \
-H "Authorization: Bearer sk-admin-key" \
-H "Content-Type: application/json" \
-d '{
"daily_tokens": 1000000,
"monthly_tokens": 10000000,
"daily_requests": 1000,
"monthly_requests": 10000
}'
```
`null` für ein Limit bedeutet unbegrenzt.
## Tests ## Tests
@ -161,32 +130,28 @@ python -m pytest tests/ -v
``` ```
llm_quota/ llm_quota/
├── backend/ ├── backend/
│ ├── main.py # Proxy-Server (Port 8000) │ ├── main.py # Proxy-Server
│ ├── admin.py # Admin-API + Static-File-Serving (Port 8001) │ ├── admin.py # Admin-API
│ ├── database.py # DB-Verbindung & Session │ ├── database.py # DB-Verbindung & Session
│ ├── models.py # SQLAlchemy-Modelle (APIKey, Setting, Usage) │ ├── models.py # SQLAlchemy-Modelle
│ ├── schemas.py # Pydantic-Schemas │ ├── schemas.py # Pydantic-Schemas
│ ├── crud.py # DB-Operationen, Token-Zählung, Quota-Logik │ ├── crud.py # DB-Operationen & Token-Zählung
│ ├── init_db.py # Tabellen anlegen & Settings seeden │ ├── init_db.py # Tabellen anlegen
│ ├── setup_admin.py # Standard-API-Key erstellen │ ├── setup_admin.py # Admin-User & API-Key erstellen
│ ├── requirements.txt # Produktiv-Dependencies │ ├── requirements.txt
│ ├── requirements-dev.txt # Test-Dependencies │ ├── Dockerfile
│ └── tests/ │ └── tests/
│ ├── conftest.py # Fixtures │ ├── conftest.py # Fixtures
│ ├── test_auth.py # Authentifizierungs-Tests │ ├── test_auth.py # Authentifizierungs-Tests
│ └── test_quota.py # Quota-, Token- und Ablauf-Tests │ └── test_quota.py # Quota- & Token-Tests
├── frontend/ ├── frontend/
│ └── src/ │ └── src/
│ ├── main.jsx # React-Admin-UI │ ├── main.jsx
│ └── styles.css │ └── styles.css
├── Dockerfile ├── .gitignore
├── docker-entrypoint.sh └── docker-compose.yml
├── .dockerignore
├── .env.example
├── start.sh # Entwicklungs-Startscript
└── .gitignore
``` ```
## Lizenz ## Lizenz
MIT MIT

View File

@ -1,128 +1,115 @@
import os import os
import secrets
import httpx
from pathlib import Path
from fastapi import FastAPI, Depends, HTTPException, Request from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
import crud, schemas import crud, schemas
from models import APIKey as APIKeyModel from models import User, APIKey, Quota
app = FastAPI(title="Ollama Proxy Admin API") app = FastAPI(title="Ollama Proxy Admin API")
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173").split(",") 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, allow_origins=ALLOWED_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"], allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"], allow_headers=["Authorization", "Content-Type"],
) )
def require_admin_auth(request: Request): async def require_admin_auth(request: Request, db: Session = Depends(get_db)):
auth = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
token = auth.removeprefix("Bearer ").strip() if auth_header.startswith("Bearer "):
if not secrets.compare_digest(token, ADMIN_PASSWORD): api_key = auth_header.replace("Bearer ", "")
raise HTTPException(status_code=401, detail="Invalid admin password") elif auth_header.startswith("sk-"):
api_key = auth_header
else:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
@app.get("/api/api-keys", response_model=list[schemas.APIKey]) db_key = crud.verify_api_key(db, api_key)
async def read_api_keys( if not db_key:
skip: int = 0, limit: int = 100, 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,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin_auth), _ = Depends(require_admin_auth)
): ):
return db.query(APIKeyModel).offset(skip).limit(limit).all() 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)
@app.post("/api/api-keys", response_model=schemas.APIKeyCreated) @app.post("/api/api-keys", response_model=schemas.APIKeyCreated)
async def create_api_key( async def create_api_key(
api_key: schemas.APIKeyCreate, api_key: schemas.APIKeyCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin_auth), _ = Depends(require_admin_auth)
): ):
db_key, raw_key = crud.create_api_key( db_user = db.query(User).filter(User.id == api_key.user_id).first()
db, if not db_user:
name=api_key.name, raise HTTPException(status_code=404, detail="User not found")
expires_at=api_key.expires_at, db_key, raw_key = crud.create_api_key(db=db, user_id=api_key.user_id, name=api_key.name)
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 = schemas.APIKeyCreated.model_validate(db_key)
result.plaintext_key = raw_key result.plaintext_key = raw_key
return result return result
@app.patch("/api/api-keys/{api_key_id}/quota", response_model=schemas.APIKey) @app.get("/api/api-keys", response_model=list[schemas.APIKey])
async def update_quota( async def read_api_keys(
api_key_id: int, skip: int = 0,
quota: schemas.QuotaUpdate, limit: int = 100,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin_auth), _ = Depends(require_admin_auth)
): ):
db_key = db.query(APIKeyModel).filter(APIKeyModel.id == api_key_id).first() api_keys = db.query(APIKey).offset(skip).limit(limit).all()
if not db_key: return api_keys
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") @app.put("/api/api-keys/{api_key_id}/deactivate")
async def deactivate_api_key( async def deactivate_api_key(
api_key_id: int, api_key_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin_auth), _ = Depends(require_admin_auth)
): ):
db_key = db.query(APIKeyModel).filter(APIKeyModel.id == api_key_id).first() db_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
if not db_key: if not db_key:
raise HTTPException(status_code=404, detail="API key not found") raise HTTPException(status_code=404, detail="API key not found")
db_key.is_active = False db_key.is_active = False
db.commit() db.commit()
return {"message": "API key deactivated"} return {"message": "API key deactivated"}
@app.get("/api/proxy-info") @app.put("/api/quotas/{user_id}", response_model=schemas.Quota)
async def get_proxy_info(_ = Depends(require_admin_auth)): async def update_quota(
host = os.getenv("PROXY_HOST", "0.0.0.0") user_id: int,
port = os.getenv("PROXY_PORT", "8000") quota: schemas.QuotaCreate,
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), db: Session = Depends(get_db),
_ = Depends(require_admin_auth), _ = Depends(require_admin_auth)
): ):
crud.set_setting(db, "ollama_url", settings.ollama_url) db_quota = db.query(Quota).filter(Quota.user_id == user_id).first()
crud.set_setting(db, "default_model", settings.default_model) if not db_quota:
return settings raise HTTPException(status_code=404, detail="Quota not found")
for key, value in quota.model_dump(exclude_unset=True).items():
@app.get("/api/ollama-models") setattr(db_quota, key, value)
async def get_ollama_models(db: Session = Depends(get_db), _ = Depends(require_admin_auth)): db.commit()
ollama_url = crud.get_setting(db, "ollama_url", "http://localhost:11434") db.refresh(db_quota)
try: return db_quota
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{ollama_url}/api/tags")
models = [m["name"] for m in response.json().get("models", [])]
except Exception:
models = []
return {"models": models}
# Statisches Frontend ausliefern (nur im Produktivbetrieb, wenn dist/ existiert)
_dist = Path(__file__).parent.parent / "frontend" / "dist"
if _dist.exists():
app.mount("/", StaticFiles(directory=_dist, html=True), name="frontend")

View File

@ -1,115 +1,116 @@
import os
import secrets import secrets
import hashlib import hashlib
import bcrypt
import tiktoken import tiktoken
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models import APIKey, Usage, Setting from models import APIKey, User, Quota, Usage
_encoder = tiktoken.get_encoding("cl100k_base") _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: def count_tokens(text: str) -> int:
return len(_encoder.encode(text)) 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: def _hash_api_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest() return hashlib.sha256(key.encode()).hexdigest()
def generate_api_key() -> str: 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():
return "sk-" + secrets.token_urlsafe(32) return "sk-" + secrets.token_urlsafe(32)
def create_api_key( def create_user(db: Session, username: str, email: str, password: str, is_admin: bool = False):
db: Session, db_user = User(
name: str, username=username,
expires_at: datetime = None, email=email,
daily_tokens: int = None, hashed_password=hash_password(password),
monthly_tokens: int = None, is_admin=is_admin,
daily_requests: int = None, )
monthly_requests: int = None, db.add(db_user)
) -> tuple[APIKey, str]: 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]:
raw_key = generate_api_key() raw_key = generate_api_key()
db_key = APIKey( db_key = APIKey(
name=name, name=name,
key=_hash_api_key(raw_key), key=_hash_api_key(raw_key),
expires_at=expires_at, user_id=user_id
daily_tokens=daily_tokens,
monthly_tokens=monthly_tokens,
daily_requests=daily_requests,
monthly_requests=monthly_requests,
) )
db.add(db_key) db.add(db_key)
db.commit() db.commit()
db.refresh(db_key) db.refresh(db_key)
return db_key, raw_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): def verify_api_key(db: Session, api_key: str):
key_hash = _hash_api_key(api_key) key_hash = _hash_api_key(api_key)
db_key = db.query(APIKey).filter(APIKey.key == key_hash, APIKey.is_active == True).first() return 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 check_and_increment_quota(db: Session, api_key_id: int, tokens: int = 0, requests: int = 1) -> bool: 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:
usage = ( usage = (
db.query(Usage) db.query(Usage)
.filter(Usage.api_key_id == api_key_id) .filter(Usage.user_id == user_id)
.with_for_update() .with_for_update()
.first() .first()
) )
if not usage: if not usage:
usage = Usage(api_key_id=api_key_id) usage = Usage(user_id=user_id)
db.add(usage) db.add(usage)
db.flush() db.flush()
now = _now_local() now = datetime.now(timezone.utc)
daily_reset_local = _to_local(usage.daily_reset_at)
monthly_reset_local = _to_local(usage.monthly_reset_at)
if daily_reset_local.date() < now.date(): if usage.daily_reset_at.date() < now.date():
usage.tokens_used_today = 0 usage.tokens_used_today = 0
usage.requests_today = 0 usage.requests_today = 0
usage.daily_reset_at = now usage.daily_reset_at = now
if (monthly_reset_local.year, monthly_reset_local.month) < (now.year, now.month): if (usage.monthly_reset_at.year, usage.monthly_reset_at.month) < (now.year, now.month):
usage.tokens_used_month = 0 usage.tokens_used_month = 0
usage.requests_month = 0 usage.requests_month = 0
usage.monthly_reset_at = now usage.monthly_reset_at = now
api_key = db.query(APIKey).filter(APIKey.id == api_key_id).first() quota = get_quota(db, user_id)
allowed = True allowed = True
if api_key: if quota:
if api_key.daily_tokens and (usage.tokens_used_today + tokens) > api_key.daily_tokens: if quota.daily_tokens and (usage.tokens_used_today + tokens) > quota.daily_tokens:
allowed = False allowed = False
elif api_key.monthly_tokens and (usage.tokens_used_month + tokens) > api_key.monthly_tokens: elif quota.monthly_tokens and (usage.tokens_used_month + tokens) > quota.monthly_tokens:
allowed = False allowed = False
elif api_key.daily_requests and (usage.requests_today + requests) > api_key.daily_requests: elif quota.daily_requests and (usage.requests_today + requests) > quota.daily_requests:
allowed = False allowed = False
elif api_key.monthly_requests and (usage.requests_month + requests) > api_key.monthly_requests: elif quota.monthly_requests and (usage.requests_month + requests) > quota.monthly_requests:
allowed = False allowed = False
if allowed: if allowed:
@ -119,4 +120,4 @@ def check_and_increment_quota(db: Session, api_key_id: int, tokens: int = 0, req
usage.requests_month += requests usage.requests_month += requests
db.commit() db.commit()
return allowed return allowed

View File

@ -1,18 +1,19 @@
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine from sqlalchemy import create_engine
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./test.db") try:
import os
if "sqlite" in DATABASE_URL: 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"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) 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) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()
def get_db(): def get_db():

View File

@ -1,23 +1,24 @@
import os from sqlalchemy import create_engine
from dotenv import load_dotenv from sqlalchemy.orm import sessionmaker
from database import Base
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
from database import Base, engine, SessionLocal
import models import models
from crud import get_setting, set_setting import os
SQLALCHEMY_DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./test.db")
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)
def init_db(): def init_db():
Base.metadata.create_all(bind=engine) 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__": if __name__ == "__main__":
init_db() init_db()

View File

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

View File

@ -4,34 +4,46 @@ from database import Base
_now = lambda: datetime.now(timezone.utc) _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): class APIKey(Base):
__tablename__ = "api_keys" __tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String) name = Column(String)
key = Column(String, unique=True, index=True) key = Column(String, unique=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), default=_now) created_at = Column(DateTime(timezone=True), default=_now)
expires_at = Column(DateTime(timezone=True), nullable=True)
class Quota(Base):
__tablename__ = "quotas"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
daily_tokens = Column(BigInteger, nullable=True) daily_tokens = Column(BigInteger, nullable=True)
monthly_tokens = Column(BigInteger, nullable=True) monthly_tokens = Column(BigInteger, nullable=True)
daily_requests = Column(Integer, nullable=True) daily_requests = Column(Integer, nullable=True)
monthly_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): class Usage(Base):
__tablename__ = "usage" __tablename__ = "usage"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
api_key_id = Column(Integer, ForeignKey("api_keys.id"), unique=True) user_id = Column(Integer, ForeignKey("users.id"), unique=True)
tokens_used_today = Column(BigInteger, default=0) tokens_used_today = Column(BigInteger, default=0)
tokens_used_month = Column(BigInteger, default=0) tokens_used_month = Column(BigInteger, default=0)
requests_today = Column(Integer, default=0) requests_today = Column(Integer, default=0)
requests_month = Column(Integer, default=0) requests_month = Column(Integer, default=0)
daily_reset_at = Column(DateTime(timezone=True), default=_now) daily_reset_at = Column(DateTime(timezone=True), default=_now)
monthly_reset_at = Column(DateTime(timezone=True), default=_now) monthly_reset_at = Column(DateTime(timezone=True), default=_now)

View File

@ -1,4 +0,0 @@
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.25.1
pytest-cov==6.0.0

View File

@ -1,6 +1,5 @@
fastapi==0.115.6 fastapi==0.115.6
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
aiofiles==24.1.0
httpx==0.28.1 httpx==0.28.1
sqlalchemy==2.0.36 sqlalchemy==2.0.36
alembic==1.14.0 alembic==1.14.0
@ -10,3 +9,6 @@ python-jose[cryptography]==3.3.0
bcrypt==5.0.0 bcrypt==5.0.0
tiktoken==0.9.0 tiktoken==0.9.0
python-dotenv==1.0.1 python-dotenv==1.0.1
pytest==8.3.4
pytest-asyncio==0.25.1
pytest-cov==6.0.0

View File

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

View File

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

View File

@ -6,23 +6,68 @@ os.environ.setdefault("OLLAMA_URL", "http://127.0.0.1:9999")
def _setup_db(): def _setup_db():
from database import Base, engine, SessionLocal from database import Base, engine, SessionLocal
from crud import create_api_key from models import User, Quota
from crud import create_api_key, hash_password
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
db = SessionLocal() db = SessionLocal()
_, raw_key = create_api_key(db, name="test-key", daily_tokens=1_000_000,
monthly_tokens=10_000_000, daily_requests=1000, test_user = User(
monthly_requests=10000) 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")
os.environ["TEST_API_KEY"] = raw_key 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() db.close()
def _teardown_db(): def _teardown_db():
from database import engine, Base from database import engine
from models import Base
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
os.environ.pop("TEST_API_KEY", None) os.environ.pop("TEST_API_KEY", None)
os.environ.pop("ADMIN_API_KEY", None)
@pytest.fixture(scope="function") @pytest.fixture(scope="function")

View File

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

View File

@ -1,19 +0,0 @@
#!/bin/bash
set -e
cd /app/backend
python3 init_db.py
uvicorn main:app \
--host "${PROXY_HOST:-0.0.0.0}" \
--port "${PROXY_PORT:-8000}" &
PROXY_PID=$!
uvicorn admin:app \
--host "0.0.0.0" \
--port "${ADMIN_PORT:-8001}" &
ADMIN_PID=$!
# Beendet den Container wenn einer der Prozesse stirbt
wait -n
kill "$PROXY_PID" "$ADMIN_PID" 2>/dev/null

View File

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

View File

@ -44,177 +44,12 @@ tr:hover {
background: #f8f9fa; background: #f8f9fa;
} }
.status-active {
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
}
.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; color: #27ae60;
font-size: 14px; font-weight: 600;
} }
.settings-value { .status-inactive {
flex: 1; color: #e74c3c;
font-size: 14px; font-weight: 600;
color: #2c3e50;
}
.settings-value small {
margin-left: 8px;
color: #999;
font-size: 12px;
} }

View File

@ -3,13 +3,8 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
clearScreen: false,
server: { server: {
proxy: { 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', '/api': 'http://localhost:8000',
}, },
}, },

View File

@ -1,81 +0,0 @@
#!/bin/bash
# .env laden
if [ -f .env ]; then
set -a
source .env
set +a
fi
# Virtuelle Umgebung aktivieren falls vorhanden
if [ -f .venv/bin/activate ]; then
source .venv/bin/activate
elif [ -f venv/bin/activate ]; then
source venv/bin/activate
fi
if [ -z "$ADMIN_PASSWORD" ]; then
echo "Fehler: ADMIN_PASSWORD ist nicht gesetzt. Bitte .env befüllen."
exit 1
fi
PROXY_HOST=${PROXY_HOST:-0.0.0.0}
PROXY_PORT=${PROXY_PORT:-8000}
ADMIN_PORT=${ADMIN_PORT:-8001}
FRONTEND_PORT=5173
PIDS=()
cleanup() {
echo "Beende alle Prozesse..."
for pid in "${PIDS[@]}"; do
kill "$pid" 2>/dev/null
done
wait 2>/dev/null
}
port_in_use() {
lsof -iTCP:"$1" -sTCP:LISTEN -t &>/dev/null
}
# Ports prüfen bevor irgendetwas gestartet wird
for port in "$PROXY_PORT" "$ADMIN_PORT" "$FRONTEND_PORT"; do
if port_in_use "$port"; then
echo "Fehler: Port $port ist bereits belegt."
exit 1
fi
done
trap cleanup EXIT INT TERM
# Datenbank initialisieren
echo "Initialisiere Datenbank..."
cd backend
python3 init_db.py
cd ..
# 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" &
PIDS+=($!)
# 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" &
PIDS+=($!)
cd ..
# Frontend starten
echo "Starte Frontend..."
cd frontend
npm install --silent
npm run dev &
PIDS+=($!)
cd ..
echo "Backend läuft (Port $PROXY_PORT)"
echo "Admin-API läuft (Port $ADMIN_PORT, nur lokal)"
echo "Admin-Oberfläche: http://localhost:$FRONTEND_PORT"
wait