Fix medium/low priority review items; update README
Medium: - Frontend: Error-Handling in fetchUsers/fetchApiKeys (try/catch) - Frontend: Loading-Race behoben (Promise.all + .finally) - Frontend: API-Keys maskiert (nur letzte 4 Zeichen sichtbar) - Tests: Setup-Code aus test_auth.py in conftest.py konsolidiert - Tests: Fixture-Scope vereinheitlicht (function statt session) Low: - bare except in database.py → except Exception - datetime.utcnow → datetime.now(timezone.utc) durchgängig - DateTime(timezone=True) in allen Modell-Spalten - .gitignore hinzugefügt (.env, *.db, __pycache__, .idea, node_modules) Docs: - README aktualisiert (Sicherheit, Konfiguration, Projektstruktur, Tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bf694b79e2
commit
cfa874a4c3
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Environment & secrets
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# Databases
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
config.json
|
||||||
154
README.md
154
README.md
@ -4,28 +4,41 @@ 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)
|
||||||
- 📊 Quota-Management (Tokens & Requests pro Tag/Monat)
|
- Quota-Management mit getrennten Tages- und Monatslimits (Tokens & Requests)
|
||||||
- 📈 Usage-Tracking in Echtzeit
|
- Token-Zählung via tiktoken (cl100k_base)
|
||||||
- 🖥️ Web-Admin-Oberfläche für User & Quotas
|
- Usage-Tracking mit automatischem täglichem/monatlichem Reset
|
||||||
- 🔒 Benutzer-Management
|
- Web-Admin-Oberfläche für User- und Quota-Verwaltung
|
||||||
|
- OpenAI-kompatibler `/v1/chat/completions`-Endpunkt
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Passwörter mit bcrypt gehasht
|
||||||
|
- API-Keys als SHA-256-Hash in der DB – Plaintext wird nur einmalig bei Erstellung zurückgegeben
|
||||||
|
- Admin-Zugriff über `is_admin`-Flag in der DB, nicht über Hardcoded-Namen
|
||||||
|
- CORS-Origins konfigurierbar via `ALLOWED_ORIGINS`
|
||||||
|
- Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race)
|
||||||
|
|
||||||
## Installation & Start
|
## Installation & Start
|
||||||
|
|
||||||
### Voraussetzungen
|
### Voraussetzungen
|
||||||
|
|
||||||
- Python 3.12+
|
- Python 3.12+
|
||||||
- PostgreSQL 16+
|
- PostgreSQL 16+ (oder SQLite für Entwicklung)
|
||||||
- Node.js 18+ (für Frontend)
|
- Node.js 18+ (für Frontend)
|
||||||
|
|
||||||
### lokal mit SQLite (Entwicklung)
|
### Lokal mit SQLite (Entwicklung)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Backend
|
||||||
cd backend
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
python init_db.py
|
python init_db.py
|
||||||
python setup_admin.py
|
python setup_admin.py
|
||||||
uvicorn main:app --reload
|
uvicorn main:app --reload --port 8000
|
||||||
|
|
||||||
|
# Admin-API (in neuem Terminal)
|
||||||
|
uvicorn admin:app --reload --port 8001
|
||||||
|
|
||||||
# Frontend (in neuem Terminal)
|
# Frontend (in neuem Terminal)
|
||||||
cd frontend
|
cd frontend
|
||||||
@ -33,7 +46,7 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### mit PostgreSQL & Docker (Produktion)
|
### Mit PostgreSQL & Docker (Produktion)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@ -41,18 +54,25 @@ docker compose exec backend python init_db.py
|
|||||||
docker compose exec backend python setup_admin.py
|
docker compose exec backend python setup_admin.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Konfiguration
|
||||||
|
|
||||||
### Mit API-Key authentifizieren
|
`.env`-Datei im `backend/`-Verzeichnis anlegen:
|
||||||
|
|
||||||
```bash
|
```env
|
||||||
curl -X POST http://localhost:8000/api/generate \
|
DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||||
-H "Authorization: sk-xxxxxx" \
|
OLLAMA_URL=http://ollama:11434
|
||||||
-H "Content-Type: application/json" \
|
ALLOWED_ORIGINS=https://admin.example.com
|
||||||
-d '{"model":"llama3","prompt":"Say hello"}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Oder mit Bearer Token:
|
| Variable | Standard | Beschreibung |
|
||||||
|
|----------|----------|--------------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL lokal | DB-Verbindungsstring; `sqlite:///` für SQLite |
|
||||||
|
| `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz |
|
||||||
|
| `ALLOWED_ORIGINS` | `http://localhost:5173` | Kommagetrennte CORS-Origins für die Admin-UI |
|
||||||
|
|
||||||
|
## Proxy-Endpunkte
|
||||||
|
|
||||||
|
Alle Endpunkte erfordern einen gültigen API-Key im `Authorization`-Header.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/api/generate \
|
curl -X POST http://localhost:8000/api/generate \
|
||||||
@ -61,50 +81,48 @@ curl -X POST http://localhost:8000/api/generate \
|
|||||||
-d '{"model":"llama3","prompt":"Say hello"}'
|
-d '{"model":"llama3","prompt":"Say hello"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Admin API Endpoints
|
| Endpunkt | Beschreibung |
|
||||||
|
|----------|--------------|
|
||||||
|
| `POST /api/generate` | Ollama generate |
|
||||||
|
| `POST /api/chat` | Ollama chat |
|
||||||
|
| `GET /api/tags` | Verfügbare Modelle |
|
||||||
|
| `GET /v1/models` | Modelle (OpenAI-Format) |
|
||||||
|
| `POST /v1/chat/completions` | Chat (OpenAI-Format) |
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
## Admin-API (Port 8001)
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/api/users` | GET | Liste aller User |
|
Alle Endpunkte erfordern einen API-Key eines Nutzers mit `is_admin=true`.
|
||||||
| `/api/users` | POST | Neuen User erstellen |
|
|
||||||
| `/api/api-keys` | GET | Liste aller API-Keys |
|
| Endpunkt | Methode | Beschreibung |
|
||||||
| `/api/api-keys` | POST | Neuen API-Key erstellen |
|
|----------|---------|--------------|
|
||||||
|
| `/api/users` | GET | Alle User auflisten |
|
||||||
|
| `/api/users` | POST | Neuen User anlegen |
|
||||||
|
| `/api/api-keys` | GET | Alle API-Keys auflisten |
|
||||||
|
| `/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/quotas/{user_id}` | PUT | Quota für User setzen |
|
| `/api/quotas/{user_id}` | PUT | Quota für User setzen |
|
||||||
|
|
||||||
### Quota-Beispiele
|
### Quota setzen
|
||||||
|
|
||||||
```json
|
```bash
|
||||||
// Quota setzen (per PUT /api/quotas/{user_id})
|
curl -X PUT http://localhost:8001/api/quotas/1 \
|
||||||
{
|
-H "Authorization: Bearer sk-admin-key" \
|
||||||
"daily_tokens": 1000000,
|
-H "Content-Type: application/json" \
|
||||||
"monthly_tokens": 10000000,
|
-d '{
|
||||||
"daily_requests": 1000,
|
"daily_tokens": 1000000,
|
||||||
"monthly_requests": 10000
|
"monthly_tokens": 10000000,
|
||||||
}
|
"daily_requests": 1000,
|
||||||
|
"monthly_requests": 10000
|
||||||
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Konfiguration
|
`null` für ein Limit bedeutet unbegrenzt.
|
||||||
|
|
||||||
### Umgebungsvariablen
|
## Tests
|
||||||
|
|
||||||
Im `backend/` Verzeichnis `.env` Datei erstellen:
|
```bash
|
||||||
|
cd backend
|
||||||
```env
|
python -m pytest tests/ -v
|
||||||
OLLAMA_URL=http://ollama:11434
|
|
||||||
DATABASE_URL=postgresql://user:pass@host:5432/db
|
|
||||||
SECRET_KEY=your-secret-key-min-32-chars
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
|
|
||||||
`docker-compose.yml` anpassen:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL=postgresql://ollama:password@db:5432/ollama_proxy
|
|
||||||
- OLLAMA_URL=http://ollama:11434
|
|
||||||
- SECRET_KEY=your-secret-key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
@ -112,26 +130,28 @@ environment:
|
|||||||
```
|
```
|
||||||
llm_quota/
|
llm_quota/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── main.py # Proxy-Server (Ollama API forwarden)
|
│ ├── main.py # Proxy-Server
|
||||||
│ ├── admin.py # Admin API (User/Quota Management)
|
│ ├── admin.py # Admin-API
|
||||||
│ ├── database.py # DB-Verbindung & Session
|
│ ├── database.py # DB-Verbindung & Session
|
||||||
│ ├── models.py # SQLAlchemy Models
|
│ ├── models.py # SQLAlchemy-Modelle
|
||||||
│ ├── schemas.py # Pydantic Schemas
|
│ ├── schemas.py # Pydantic-Schemas
|
||||||
│ ├── crud.py # Database Operations
|
│ ├── crud.py # DB-Operationen & Token-Zählung
|
||||||
│ ├── types.py # Response Types
|
│ ├── init_db.py # Tabellen anlegen
|
||||||
|
│ ├── setup_admin.py # Admin-User & API-Key erstellen
|
||||||
│ ├── requirements.txt
|
│ ├── requirements.txt
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── setup_admin.py # Admin User erstellen
|
│ └── tests/
|
||||||
|
│ ├── conftest.py # Fixtures
|
||||||
|
│ ├── test_auth.py # Authentifizierungs-Tests
|
||||||
|
│ └── test_quota.py # Quota- & Token-Tests
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── src/
|
│ └── src/
|
||||||
│ │ ├── main.jsx
|
│ ├── main.jsx
|
||||||
│ │ └── styles.css
|
│ └── styles.css
|
||||||
│ ├── index.html
|
├── .gitignore
|
||||||
│ ├── package.json
|
|
||||||
│ └── vite.config.js
|
|
||||||
└── docker-compose.yml
|
└── docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
@ -2,7 +2,7 @@ import secrets
|
|||||||
import hashlib
|
import hashlib
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import tiktoken
|
import tiktoken
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from models import APIKey, User, Quota, Usage
|
from models import APIKey, User, Quota, Usage
|
||||||
|
|
||||||
@ -89,7 +89,7 @@ def check_and_increment_quota(db: Session, user_id: int, tokens: int = 0, reques
|
|||||||
db.add(usage)
|
db.add(usage)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
if usage.daily_reset_at.date() < now.date():
|
if usage.daily_reset_at.date() < now.date():
|
||||||
usage.tokens_used_today = 0
|
usage.tokens_used_today = 0
|
||||||
|
|||||||
@ -8,7 +8,7 @@ try:
|
|||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
else:
|
else:
|
||||||
engine = create_engine(DATABASE_URL)
|
engine = create_engine(DATABASE_URL)
|
||||||
except:
|
except Exception:
|
||||||
DATABASE_URL = "sqlite:///./test.db"
|
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})
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, BigInteger
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, BigInteger
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
||||||
|
_now = lambda: datetime.now(timezone.utc)
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@ -11,7 +13,7 @@ class User(Base):
|
|||||||
hashed_password = Column(String)
|
hashed_password = Column(String)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
is_admin = Column(Boolean, default=False)
|
is_admin = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
class APIKey(Base):
|
class APIKey(Base):
|
||||||
__tablename__ = "api_keys"
|
__tablename__ = "api_keys"
|
||||||
@ -21,7 +23,7 @@ class APIKey(Base):
|
|||||||
key = Column(String, unique=True, index=True)
|
key = Column(String, unique=True, index=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"))
|
user_id = Column(Integer, ForeignKey("users.id"))
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
class Quota(Base):
|
class Quota(Base):
|
||||||
__tablename__ = "quotas"
|
__tablename__ = "quotas"
|
||||||
@ -32,7 +34,7 @@ class Quota(Base):
|
|||||||
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, default=datetime.utcnow)
|
reset_at = Column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
class Usage(Base):
|
class Usage(Base):
|
||||||
__tablename__ = "usage"
|
__tablename__ = "usage"
|
||||||
@ -43,5 +45,5 @@ class Usage(Base):
|
|||||||
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, default=datetime.utcnow)
|
daily_reset_at = Column(DateTime(timezone=True), default=_now)
|
||||||
monthly_reset_at = Column(DateTime, default=datetime.utcnow)
|
monthly_reset_at = Column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
@ -1,49 +1,41 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
import tempfile
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def create_test_db():
|
os.environ.setdefault("OLLAMA_URL", "http://127.0.0.1:9999")
|
||||||
"""Create a temporary SQLite database for tests."""
|
|
||||||
temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
|
|
||||||
temp_db.close()
|
|
||||||
os.environ["DATABASE_URL"] = f"sqlite:///{temp_db.name}"
|
|
||||||
return temp_db.name
|
|
||||||
|
|
||||||
def cleanup_test_db(db_path):
|
|
||||||
"""Remove the temporary database."""
|
|
||||||
if os.path.exists(db_path):
|
|
||||||
os.unlink(db_path)
|
|
||||||
os.environ.pop("DATABASE_URL", None)
|
|
||||||
|
|
||||||
def setup_test_db():
|
def _setup_db():
|
||||||
"""Setup test database with required data."""
|
|
||||||
from database import Base, engine, SessionLocal
|
from database import Base, engine, SessionLocal
|
||||||
from models import User, APIKey, Quota, Usage
|
from models import User, Quota
|
||||||
from crud import create_api_key, hash_password
|
from crud import create_api_key, hash_password
|
||||||
|
|
||||||
# Create tables
|
Base.metadata.drop_all(bind=engine)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
# Create test user
|
|
||||||
test_user = User(
|
test_user = User(
|
||||||
username="testuser",
|
username="testuser",
|
||||||
email="test@example.com",
|
email="test@example.com",
|
||||||
hashed_password=hash_password("test123"),
|
hashed_password=hash_password("test123"),
|
||||||
is_active=True
|
is_active=True,
|
||||||
)
|
)
|
||||||
db.add(test_user)
|
db.add(test_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test_user)
|
db.refresh(test_user)
|
||||||
|
|
||||||
# Create API key for 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, test_user.id, "test-key")
|
||||||
os.environ["TEST_API_KEY"] = raw_key
|
os.environ["TEST_API_KEY"] = raw_key
|
||||||
|
|
||||||
# Create admin user
|
|
||||||
admin_user = User(
|
admin_user = User(
|
||||||
username="admin",
|
username="admin",
|
||||||
email="admin@example.com",
|
email="admin@example.com",
|
||||||
@ -55,33 +47,34 @@ def setup_test_db():
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(admin_user)
|
db.refresh(admin_user)
|
||||||
|
|
||||||
# Create admin API key
|
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")
|
_, admin_raw_key = create_api_key(db, admin_user.id, "admin-key")
|
||||||
os.environ["ADMIN_API_KEY"] = admin_raw_key
|
os.environ["ADMIN_API_KEY"] = admin_raw_key
|
||||||
|
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
return raw_key, admin_raw_key
|
|
||||||
|
|
||||||
def teardown_test_db():
|
def _teardown_db():
|
||||||
"""Clean up test database and environment."""
|
|
||||||
from database import engine
|
from database import engine
|
||||||
from models import Base
|
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)
|
os.environ.pop("ADMIN_API_KEY", None)
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
def test_client():
|
def test_client():
|
||||||
"""Create test client with test database."""
|
_setup_db()
|
||||||
db_path = create_test_db()
|
|
||||||
setup_test_db()
|
|
||||||
|
|
||||||
from main import app
|
from main import app
|
||||||
client = TestClient(app)
|
from fastapi.testclient import TestClient
|
||||||
|
client = TestClient(app, raise_server_exceptions=False)
|
||||||
yield client
|
yield client
|
||||||
|
_teardown_db()
|
||||||
teardown_test_db()
|
|
||||||
cleanup_test_db(db_path)
|
|
||||||
|
|||||||
@ -1,106 +1,30 @@
|
|||||||
import pytest
|
|
||||||
import os
|
import os
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from main import app
|
|
||||||
from database import Base, engine, SessionLocal
|
|
||||||
from models import User, APIKey, Quota
|
|
||||||
from crud import create_api_key, hash_password
|
|
||||||
|
|
||||||
os.environ["OLLAMA_URL"] = "http://127.0.0.1:9999"
|
|
||||||
|
|
||||||
def setup_test_db():
|
|
||||||
"""Setup test database with required data."""
|
|
||||||
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)
|
|
||||||
|
|
||||||
quota = Quota(
|
|
||||||
user_id=test_user.id,
|
|
||||||
daily_tokens=1000000,
|
|
||||||
monthly_tokens=10000000,
|
|
||||||
daily_requests=1000,
|
|
||||||
monthly_requests=10000
|
|
||||||
)
|
|
||||||
db.add(quota)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
api_key_record, raw_key = create_api_key(db, test_user.id, "test-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)
|
|
||||||
|
|
||||||
admin_quota = Quota(
|
|
||||||
user_id=admin_user.id,
|
|
||||||
daily_tokens=10000000,
|
|
||||||
monthly_tokens=100000000,
|
|
||||||
daily_requests=10000,
|
|
||||||
monthly_requests=100000
|
|
||||||
)
|
|
||||||
db.add(admin_quota)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
_, admin_raw_key = create_api_key(db, admin_user.id, "admin-key")
|
|
||||||
os.environ["ADMIN_API_KEY"] = admin_raw_key
|
|
||||||
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return os.environ["TEST_API_KEY"], os.environ["ADMIN_API_KEY"]
|
|
||||||
|
|
||||||
def teardown_test_db():
|
|
||||||
"""Clean up test database and environment."""
|
|
||||||
Base.metadata.drop_all(bind=engine)
|
|
||||||
os.environ.pop("TEST_API_KEY", None)
|
|
||||||
os.environ.pop("ADMIN_API_KEY", None)
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
def test_client():
|
|
||||||
setup_test_db()
|
|
||||||
client = TestClient(app, raise_server_exceptions=False)
|
|
||||||
yield client
|
|
||||||
teardown_test_db()
|
|
||||||
|
|
||||||
def test_auth_middleware_missing_auth(test_client):
|
def test_auth_middleware_missing_auth(test_client):
|
||||||
response = test_client.post("/api/generate", json={"model": "llama3", "prompt": "test"})
|
response = test_client.post("/api/generate", json={"model": "llama3", "prompt": "test"})
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_auth_middleware_invalid_key(test_client):
|
def test_auth_middleware_invalid_key(test_client):
|
||||||
response = test_client.post(
|
response = test_client.post(
|
||||||
"/api/generate",
|
"/api/generate",
|
||||||
headers={"Authorization": "sk-invalid-key"},
|
headers={"Authorization": "sk-invalid-key"},
|
||||||
json={"model": "llama3", "prompt": "test"}
|
json={"model": "llama3", "prompt": "test"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@patch("main.proxy_request", new_callable=AsyncMock)
|
@patch("main.proxy_request", new_callable=AsyncMock)
|
||||||
def test_auth_middleware_valid_key(mock_proxy, test_client):
|
def test_auth_middleware_valid_key(mock_proxy, test_client):
|
||||||
mock_proxy.return_value.status_code = 200
|
mock_proxy.return_value.status_code = 200
|
||||||
mock_proxy.return_value.json = lambda: {"response": "success"}
|
mock_proxy.return_value.json = lambda: {"response": "success"}
|
||||||
mock_proxy.return_value.headers = {}
|
mock_proxy.return_value.headers = {}
|
||||||
|
|
||||||
response = test_client.post(
|
response = test_client.post(
|
||||||
"/api/generate",
|
"/api/generate",
|
||||||
headers={"Authorization": os.environ.get("TEST_API_KEY", "")},
|
headers={"Authorization": os.environ.get("TEST_API_KEY", "")},
|
||||||
json={"model": "llama3", "prompt": "test"}
|
json={"model": "llama3", "prompt": "test"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta
|
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")
|
||||||
|
|
||||||
@ -126,7 +126,7 @@ def test_daily_reset_restores_access(db):
|
|||||||
|
|
||||||
# Backdate daily_reset_at to yesterday
|
# Backdate daily_reset_at to yesterday
|
||||||
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
|
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
|
||||||
usage.daily_reset_at = datetime.utcnow() - timedelta(days=1)
|
usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Should pass again after reset
|
# Should pass again after reset
|
||||||
@ -140,7 +140,7 @@ def test_daily_reset_does_not_affect_monthly_counter(db):
|
|||||||
check_and_increment_quota(db, user_id, tokens=50, requests=1)
|
check_and_increment_quota(db, user_id, tokens=50, requests=1)
|
||||||
|
|
||||||
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
|
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
|
||||||
usage.daily_reset_at = datetime.utcnow() - timedelta(days=1)
|
usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
check_and_increment_quota(db, user_id, tokens=50, requests=1)
|
check_and_increment_quota(db, user_id, tokens=50, requests=1)
|
||||||
@ -154,7 +154,7 @@ def test_monthly_reset_restores_access(db):
|
|||||||
check_and_increment_quota(db, user_id, tokens=90, requests=1)
|
check_and_increment_quota(db, user_id, tokens=90, requests=1)
|
||||||
|
|
||||||
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
|
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
|
||||||
usage.monthly_reset_at = datetime.utcnow() - timedelta(days=32)
|
usage.monthly_reset_at = datetime.now(timezone.utc) - timedelta(days=32)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
assert check_and_increment_quota(db, user_id, tokens=90, requests=1) is True
|
assert check_and_increment_quota(db, user_id, tokens=90, requests=1) is True
|
||||||
@ -168,7 +168,7 @@ def test_failed_quota_check_still_commits_reset(db):
|
|||||||
|
|
||||||
# Backdate so a reset fires, but the new request still exceeds the limit
|
# 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.user_id == user_id).first()
|
||||||
usage.daily_reset_at = datetime.utcnow() - 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()
|
||||||
|
|
||||||
|
|||||||
@ -2,33 +2,43 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
const maskKey = (key) => `••••••••${key.slice(-4)}`;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [users, setUsers] = useState([]);
|
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
Promise.all([fetchUsers(), fetchApiKeys()]).finally(() => setLoading(false));
|
||||||
fetchApiKeys();
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
const res = await axios.get('/api/users');
|
try {
|
||||||
setUsers(res.data);
|
const res = await axios.get('/api/users');
|
||||||
|
setUsers(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Benutzer konnten nicht geladen werden.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchApiKeys = async () => {
|
const fetchApiKeys = async () => {
|
||||||
const res = await axios.get('/api/api-keys');
|
try {
|
||||||
setApiKeys(res.data);
|
const res = await axios.get('/api/api-keys');
|
||||||
|
setApiKeys(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('API-Keys konnten nicht geladen werden.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (loading) return <div>Loading...</div>;
|
||||||
|
if (error) return <div className="error">{error}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1>Ollama Proxy Admin</h1>
|
<h1>Ollama Proxy Admin</h1>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<table>
|
<table>
|
||||||
@ -70,7 +80,7 @@ function App() {
|
|||||||
<tr key={key.id}>
|
<tr key={key.id}>
|
||||||
<td>{key.id}</td>
|
<td>{key.id}</td>
|
||||||
<td>{key.name}</td>
|
<td>{key.name}</td>
|
||||||
<td>{key.key}</td>
|
<td>{maskKey(key.key)}</td>
|
||||||
<td>{key.user_id}</td>
|
<td>{key.user_id}</td>
|
||||||
<td>{key.is_active ? 'Active' : 'Inactive'}</td>
|
<td>{key.is_active ? 'Active' : 'Inactive'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -82,4 +92,4 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
Loading…
x
Reference in New Issue
Block a user