28 Commits
Author SHA1 Message Date
Oliver Hofmann 280b3b0762 Add open: true to Vite dev server config 2026-04-29 17:13:14 +02:00
Oliver Hofmann 89661dafcc Update README: new endpoints, Docker Hub, streaming, PyCharm config, port 8001 security 2026-04-29 10:19:31 +02:00
Oliver Hofmann 3ccc7f325d Explain port 8001 security model in docs 2026-04-29 10:17:59 +02:00
Oliver Hofmann 25f19b6ada Show reset date below quota progress bars in admin UI 2026-04-29 09:55:25 +02:00
Oliver Hofmann 5b97ed0ef7 Expose port 8001 on 127.0.0.1 only, explain why in docs 2026-04-29 09:52:13 +02:00
Oliver Hofmann 222b204d4b build_push.sh: show current tag, offer to set new one before build 2026-04-29 09:36:50 +02:00
Oliver Hofmann 9910e6e062 Exclude .claude from Docker build context 2026-04-29 09:35:17 +02:00
Oliver Hofmann 6e704da86b Expand .dockerignore with standard entries 2026-04-29 09:34:19 +02:00
Oliver Hofmann bd0dc0478f Add local docker-compose pulling from DockerHub with SQLite and .env 2026-04-29 09:33:04 +02:00
Oliver Hofmann 92fd66381f Add English version of DockerHub README 2026-04-29 09:19:28 +02:00
Oliver Hofmann c46c1c2b12 Clarify that Ollama can run remotely, not just on Docker host 2026-04-29 08:51:23 +02:00
Oliver Hofmann d243523906 Split host-Ollama compose into SQLite and PostgreSQL variants 2026-04-29 08:47:40 +02:00
Oliver Hofmann 2319434b0f Add host-Ollama compose variant, fix leftover port 8001 exposure 2026-04-29 08:43:54 +02:00
Oliver Hofmann 7912431ffb Remove ALLOWED_ORIGINS from DockerHub docs (dev-only setting) 2026-04-29 08:38:38 +02:00
Oliver Hofmann 114c45dc7e Remove port 8001 exposure from all Docker examples 2026-04-29 08:36:59 +02:00
Oliver Hofmann 6f67f29f68 Fix depends_on syntax in PostgreSQL compose example 2026-04-29 08:31:25 +02:00
Oliver Hofmann 3b7c88bf2c Install psycopg2-binary in Dockerfile only, not in dev requirements 2026-04-29 08:30:16 +02:00
Oliver Hofmann 03712fe401 Add DockerHub README with usage, env vars and compose examples 2026-04-29 08:26:47 +02:00
Oliver Hofmann a0512b3b2b Replace build_push.py with build_push.sh, use shell script run config 2026-04-29 08:23:07 +02:00
Oliver Hofmann 511e85ea79 Add Docker build/push run config for arm64 2026-04-29 08:20:15 +02:00
Oliver Hofmann cdaec894d8 Add PyCharm Dev run config and dev runner script
- run_dev.py: starts proxy, admin and frontend together with colored
  output per process; handles SIGTERM from PyCharm Stop button
- .idea/runConfigurations/Dev.xml: PyCharm Python run config
- .gitignore: allow Dev.xml via layered negation pattern
- vite.config.js: open browser automatically on dev server start
2026-04-29 08:14:33 +02:00
Oliver HofmannandClaude Sonnet 4.6 dd8f69ecb6 Proxy fixes, streaming support, Admin-UI overhaul
Backend:
- Fix Content-Length mismatch by not forwarding client headers to Ollama
- Proxy /v1/chat/completions directly to Ollama's OpenAI-compatible endpoint
  (eliminates manual Ollama↔OpenAI format conversion, fixes tool use)
- Add streaming support via SSE passthrough
- Fix ollama_url /v1 suffix stripped on save
- Replace BaseHTTPMiddleware with FastAPI global dependency (fixes double logging)
- Add rotating usage log (8 KB, logs key name + model + token estimate + prompt preview)
- Add httpx timeout 300s
- Add activate and delete endpoints for API keys
- Return usage data (tokens/requests) in GET /api/api-keys

Frontend:
- Admin table: remove ID column, status as icon, icon-only action buttons with CSS tooltips
- Add activate + delete buttons; edit available for inactive keys too
- Quota columns: fixed equal width, progress bars with k-unit formatting
- Create form: structured layout matching edit form style
- Edit form: token inputs in k units (÷1000 display, ×1000 on save)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 07:48:10 +02:00
Oliver Hofmann c62cafc202 Store key_prefix for readable key display instead of masked hash
The last-4 of the SHA-256 hash was meaningless for identification.
Now storing the first 12 chars of the plaintext key as key_prefix,
displayed as 'sk-aBcDeFgH••••••••' — consistent with what the user
sees at creation time and how GitHub/OpenAI handle it.
2026-04-28 10:23:37 +02:00
Oliver Hofmann 94368670b7 Reload Ollama models on URL change, pre-select current model
- /api/ollama-models accepts optional url query param to query a different endpoint
- Frontend fetches models on load and on Ollama URL blur
- Keeps current model selected if available, otherwise selects first in list
- Shows loading indicator while fetching
2026-04-28 09:07:53 +02:00
Oliver Hofmann f22bad6496 Clarify Docker port binding and SSH tunnel for admin access
Use 127.0.0.1:8001:8001 to bind admin port locally only.
Explain Docker's 0.0.0.0 vs 127.0.0.1 distinction and add
SSH tunnel diagram showing how admin UI is accessed remotely.
2026-04-28 08:52:26 +02:00
Oliver HofmannandClaude Sonnet 4.6 ff5c88ecfd Remove admin port binding from docker run example
Port 8001 should not be exposed to the host directly.
Add nginx reverse proxy and SSH tunnel examples instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:41:18 +02:00
Oliver HofmannandClaude Sonnet 4.6 317c7f0340 Add Docker production build and update README
- Multi-stage Dockerfile: builds frontend, packages with Python backend
- admin.py serves frontend/dist as StaticFiles in production
- docker-entrypoint.sh runs proxy + admin-api, exits cleanly if either dies
- .dockerignore excludes .env, venv, tests, node_modules
- Split requirements.txt (prod) / requirements-dev.txt (dev+test)
- aiofiles added for StaticFiles support
- start.sh: port checks before startup, venv auto-activation, trap cleanup
- vite.config.js: clearScreen disabled
- README rewritten to reflect current architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:34:45 +02:00
Oliver HofmannandClaude Sonnet 4.6 c8235ec274 Refactor to flat APIKey model with quota, admin UI, .env config, and Berlin timezone
- Remove User/Quota models; quota fields now live directly on APIKey
- Admin UI: login, API key management, settings (Ollama URL/model), proxy info display
- .env/.env.example: ADMIN_PASSWORD, PROXY_HOST/PORT, DATABASE_URL, APP_TZ
- Admin API runs on 127.0.0.1 only; proxy host/port configurable
- API keys support optional expires_at; verified against Europe/Berlin timezone
- Daily/monthly quota resets use Europe/Berlin midnight boundary
- Fix all tests to use new flat model; add expiry tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 08:21:42 +02:00
30 changed files with 4344 additions and 665 deletions

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
*.egg-info/
.pytest_cache/
# Tests
backend/tests/
# Environment & secrets
.env
*.env.local
# Databases
*.db
*.sqlite3
# Logs
logs/
*.log
# IDE & tools
.idea/
.vscode/
.claude/
# Git
.git/
.gitignore
# Frontend
frontend/node_modules/
frontend/dist/
# Docker
docker-compose.yml
.dockerignore
# Docs
*.md
# Dev & build scripts
run_dev.py
build_push.sh
start.sh
test_api.sh
+18
View File
@@ -0,0 +1,18 @@
# Admin-Passwort für die Weboberfläche
ADMIN_PASSWORD=change-me
# Lokaler Endpunkt des Proxys (Admin-API bindet immer auf 127.0.0.1)
PROXY_HOST=0.0.0.0
PROXY_PORT=8000
ADMIN_PORT=8001
# Datenbankverbindung (Standard: SQLite für Entwicklung)
DATABASE_URL=sqlite:///./test.db
# Beispiel für PostgreSQL (Produktion):
# DATABASE_URL=postgresql://user:password@localhost:5432/llm_quota
# Ollama-Einstellungen (auch in der Admin-Oberfläche änderbar)
OLLAMA_URL=http://localhost:11434
DEFAULT_MODEL=llama3
APP_TZ=Europe/Berlin
+5 -1
View File
@@ -14,7 +14,11 @@ __pycache__/
*.sqlite3 *.sqlite3
# IDE # IDE
.idea/ .idea/*
!.idea/runConfigurations/
.idea/runConfigurations/*
!.idea/runConfigurations/Dev.xml
!.idea/runConfigurations/Docker_Push.xml
.vscode/ .vscode/
# Frontend build # Frontend build
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Dev" type="PythonConfigurationType" factoryName="Python">
<module name="llm_quota" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/run_dev.py" />
<option name="PARAMETERS" value="" />
<method v="2" />
</configuration>
</component>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Docker Push" type="ShConfigurationType">
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/build_push.sh" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<method v="2" />
</configuration>
</component>
+221
View File
@@ -0,0 +1,221 @@
# mediaeng/llmproxy
A lightweight reverse proxy for [Ollama](https://ollama.com) that manages API keys with configurable token and request quotas. Incoming requests in OpenAI-compatible format are authenticated, checked against the quota, and forwarded to the configured Ollama server.
Ollama does not need to run on the same host — `OLLAMA_URL` can point to any reachable server: the Docker host itself, another machine on the network, or a remote server.
## Features
- OpenAI-compatible endpoint (`/v1/chat/completions`, `/v1/models`)
- API key management with daily and monthly token/request limits
- Web-based admin interface (port 8001)
- Streaming support (Server-Sent Events)
- Tool use / function calling passthrough
- Rotating usage logs
- SQLite (default) or PostgreSQL
## Ports
| Port | Service |
|------|---------|
| `8000` | Proxy endpoint (OpenAI API) |
| `8001` | Admin API + web interface |
Port 8001 must be exposed because the container serves the admin interface directly on this port. All API endpoints require the `ADMIN_PASSWORD` — without a valid token, only the public frontend files (HTML/JS/CSS of the login page) are accessible. The password is therefore the primary protection.
Additional hardening: binding to `127.0.0.1` restricts access to the local host and prevents direct network access:
```
ports:
- "127.0.0.1:8001:8001" # local access only
# or:
- "8001:8001" # network-wide, protected by ADMIN_PASSWORD only
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `ADMIN_PASSWORD` | – | **Required.** Password for the admin interface |
| `OLLAMA_URL` | `http://localhost:11434` | URL of the Ollama server (without `/v1` suffix) |
| `DEFAULT_MODEL` | `llama3` | Model used when the client does not specify one |
| `DATABASE_URL` | `sqlite:///./test.db` | Database connection string (SQLite or PostgreSQL) |
| `PROXY_HOST` | `0.0.0.0` | Proxy bind address |
| `PROXY_PORT` | `8000` | Proxy port |
| `ADMIN_PORT` | `8001` | Admin API port |
| `APP_TZ` | `Europe/Berlin` | Timezone for daily/monthly quota resets |
| `LOG_FILE` | `logs/usage.log` | Path of the rotating usage log file |
## Docker Compose – External Ollama, SQLite
Use this when Ollama runs outside of Docker — on the Docker host or any other reachable server. Adjust `OLLAMA_URL` accordingly.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
- "127.0.0.1:8001:8001"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # or http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes:
- llmproxy-data:/app/backend
# On Linux, add extra_hosts since host.docker.internal is not
# available automatically:
# extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
llmproxy-data:
```
## Docker Compose – External Ollama, PostgreSQL
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
- "127.0.0.1:8001:8001"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # or http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
volumes:
- llmproxy-data:/app/backend
depends_on:
db:
condition: service_healthy
# extra_hosts:
# - "host.docker.internal:host-gateway"
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: llmproxy
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U llmproxy"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pg-data:
```
## Docker Compose – Ollama as Container, SQLite
Ollama and llmproxy run together in Docker, data persisted in a volume.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
- "127.0.0.1:8001:8001"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes:
- llmproxy-data:/app/backend
depends_on:
- ollama
ollama:
image: ollama/ollama:latest
restart: unless-stopped
volumes:
- ollama-data:/root/.ollama
volumes:
llmproxy-data:
ollama-data:
```
## Docker Compose – Ollama as Container, PostgreSQL
For production environments with an external database.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
- "127.0.0.1:8001:8001"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
depends_on:
db:
condition: service_healthy
ollama:
condition: service_started
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: llmproxy
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U llmproxy"]
interval: 5s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama:latest
restart: unless-stopped
volumes:
- ollama-data:/root/.ollama
volumes:
pg-data:
ollama-data:
```
## Quick Start
```bash
docker run -d \
-p 8000:8000 \
-e ADMIN_PASSWORD=changeme \
-e OLLAMA_URL=http://host.docker.internal:11434 \
-v llmproxy-data:/app/backend \
mediaeng/llmproxy:latest
```
## Client Configuration
Configure the proxy as an OpenAI-compatible endpoint:
```
Base URL: http://<host>:8000/v1
API Key: <API key created in the admin interface>
```
+221
View File
@@ -0,0 +1,221 @@
# mediaeng/llmproxy
Ein schlanker Reverse-Proxy für [Ollama](https://ollama.com), der API-Keys mit konfigurierbaren Token- und Request-Quoten verwaltet. Eingehende Anfragen im OpenAI-kompatiblen Format werden authentifiziert, auf Quota geprüft und an den konfigurierten Ollama-Server weitergeleitet.
Ollama muss dabei nicht auf demselben Host laufen — `OLLAMA_URL` kann auf jeden erreichbaren Server zeigen, also auf den Docker-Host selbst, einen anderen Rechner im Netzwerk oder einen Remote-Server.
## Funktionen
- OpenAI-kompatibler Endpunkt (`/v1/chat/completions`, `/v1/models`)
- API-Key-Verwaltung mit tages- und monatlichen Token-/Request-Limits
- Web-basierte Admin-Oberfläche (Port 8001)
- Streaming-Support (Server-Sent Events)
- Tool-Use / Function Calling wird durchgereicht
- Rotierende Nutzungs-Logs
- SQLite (Standard) oder PostgreSQL
## Ports
| Port | Dienst |
|------|--------|
| `8000` | Proxy-Endpunkt (OpenAI-API) |
| `8001` | Admin-API + Web-Oberfläche |
Port 8001 muss exposed werden, da der Container die Admin-Oberfläche selbst auf diesem Port ausliefert. Alle API-Endpunkte erfordern das `ADMIN_PASSWORD` — ein Zugriff ohne gültiges Token liefert nur die öffentlichen Frontend-Dateien (HTML/JS/CSS der Login-Seite). Das Passwort ist damit die primäre Schutzmaßnahme.
Zusätzliche Härtung: Portbindung auf `127.0.0.1` beschränkt den Zugriff auf den lokalen Host und verhindert direkten Netzwerkzugriff:
```
ports:
- "127.0.0.1:8001:8001" # nur lokal erreichbar
# oder:
- "8001:8001" # netzwerkweit, Schutz nur durch ADMIN_PASSWORD
```
## Umgebungsvariablen
| Variable | Standard | Beschreibung |
|----------|----------|--------------|
| `ADMIN_PASSWORD` | – | **Pflicht.** Passwort für die Admin-Oberfläche |
| `OLLAMA_URL` | `http://localhost:11434` | URL des Ollama-Servers (ohne `/v1`-Suffix) |
| `DEFAULT_MODEL` | `llama3` | Modell, das verwendet wird wenn der Client keines angibt |
| `DATABASE_URL` | `sqlite:///./test.db` | Datenbank-Verbindungsstring (SQLite oder PostgreSQL) |
| `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxy |
| `PROXY_PORT` | `8000` | Port des Proxy |
| `ADMIN_PORT` | `8001` | Port der Admin-API |
| `APP_TZ` | `Europe/Berlin` | Zeitzone für Tages-/Monats-Reset der Quoten |
| `LOG_FILE` | `logs/usage.log` | Pfad der rotierenden Nutzungs-Logdatei |
## Docker Compose – Ollama extern, SQLite
Wenn Ollama außerhalb von Docker läuft — auf dem Docker-Host oder einem anderen erreichbaren Server. `OLLAMA_URL` entsprechend anpassen.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
- "127.0.0.1:8001:8001"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # oder http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes:
- llmproxy-data:/app/backend
# Auf Linux extra_hosts ergänzen, da host.docker.internal dort
# nicht automatisch verfügbar ist:
# extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
llmproxy-data:
```
## Docker Compose – Ollama extern, PostgreSQL
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
- "127.0.0.1:8001:8001"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # oder http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
volumes:
- llmproxy-data:/app/backend
depends_on:
db:
condition: service_healthy
# extra_hosts:
# - "host.docker.internal:host-gateway"
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: llmproxy
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U llmproxy"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pg-data:
```
## Docker Compose – Ollama als Container, SQLite
Ollama und llmproxy laufen gemeinsam in Docker, Daten in einem Volume.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
- "127.0.0.1:8001:8001"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes:
- llmproxy-data:/app/backend
depends_on:
- ollama
ollama:
image: ollama/ollama:latest
restart: unless-stopped
volumes:
- ollama-data:/root/.ollama
volumes:
llmproxy-data:
ollama-data:
```
## Docker Compose – Ollama als Container, PostgreSQL
Für Produktionsumgebungen mit externer Datenbank.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
- "127.0.0.1:8001:8001"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
depends_on:
db:
condition: service_healthy
ollama:
condition: service_started
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: llmproxy
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U llmproxy"]
interval: 5s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama:latest
restart: unless-stopped
volumes:
- ollama-data:/root/.ollama
volumes:
pg-data:
ollama-data:
```
## Schnellstart
```bash
docker run -d \
-p 8000:8000 \
-e ADMIN_PASSWORD=changeme \
-e OLLAMA_URL=http://host.docker.internal:11434 \
-v llmproxy-data:/app/backend \
mediaeng/llmproxy:latest
```
## Client-Konfiguration
Den Proxy als OpenAI-kompatibler Endpunkt konfigurieren:
```
Base URL: http://<host>:8000/v1
API Key: <angelegter API-Key aus der Admin-Oberfläche>
```
+22
View File
@@ -0,0 +1,22 @@
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 psycopg2-binary
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"]
+138 -104
View File
@@ -1,122 +1,147 @@
# Ollama Proxy mit API-Keys und Quotas # Ollama Proxy mit API-Keys und Quotas
Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung und Quota-Management. Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung, Quota-Management und Web-Admin-Oberfläche.
## 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 (cl100k_base) - Token-Zählung via tiktoken, Reset-Grenzen in der Zeitzone Europe/Berlin
- Usage-Tracking mit automatischem täglichem/monatlichem Reset - Web-Admin-Oberfläche (API-Keys verwalten, Ollama-Einstellungen, Verbrauchsanzeige)
- Web-Admin-Oberfläche für User- und Quota-Verwaltung - OpenAI-kompatibler `/v1/chat/completions`-Endpunkt mit Streaming und Tool-Use
- OpenAI-kompatibler `/v1/chat/completions`-Endpunkt - Rotierende Nutzungs-Logs
- SQLite (Standard) oder PostgreSQL
- Docker-Image auf DockerHub: `mediaeng/llmproxy`
## Sicherheit ## Sicherheit
- Passwörter mit bcrypt gehasht - Admin-Oberfläche passwortgeschützt (`ADMIN_PASSWORD`) — alle API-Endpunkte erfordern den Token
- 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
- CORS-Origins konfigurierbar via `ALLOWED_ORIGINS`
- Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race) - Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race)
- Port 8001 kann optional auf `127.0.0.1` gebunden werden (zusätzliche Härtung)
## 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 `backend/`-Verzeichnis anlegen: `.env`-Datei im Projektverzeichnis anlegen:
```env ```env
DATABASE_URL=postgresql://user:pass@host:5432/db ADMIN_PASSWORD=change-me
OLLAMA_URL=http://ollama:11434 PROXY_HOST=0.0.0.0
ALLOWED_ORIGINS=https://admin.example.com PROXY_PORT=8000
ADMIN_PORT=8001
DATABASE_URL=sqlite:///./test.db
OLLAMA_URL=http://localhost:11434
DEFAULT_MODEL=llama3
APP_TZ=Europe/Berlin
LOG_FILE=logs/usage.log
``` ```
| Variable | Standard | Beschreibung | | Variable | Standard | Beschreibung |
|----------|----------|--------------| |----------|----------|--------------|
| `DATABASE_URL` | PostgreSQL lokal | DB-Verbindungsstring; `sqlite:///` für SQLite | | `ADMIN_PASSWORD` | — | Passwort für die Admin-Oberfläche (**Pflicht**) |
| `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz | | `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxys |
| `ALLOWED_ORIGINS` | `http://localhost:5173` | Kommagetrennte CORS-Origins für die Admin-UI | | `PROXY_PORT` | `8000` | Port des Proxys |
| `ADMIN_PORT` | `8001` | Port der Admin-API |
| `DATABASE_URL` | `sqlite:///./test.db` | DB-Verbindungsstring (SQLite oder PostgreSQL) |
| `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 |
| `LOG_FILE` | `logs/usage.log` | Pfad der rotierenden Nutzungs-Logdatei |
| `ALLOWED_ORIGINS` | `http://localhost:5173` | CORS-Origins (nur für Entwicklung relevant) |
## Proxy-Endpunkte ## Entwicklung (lokal)
### 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
```
### Starten
**Per Script:**
```bash
cp .env.example .env # ADMIN_PASSWORD setzen
./start.sh
```
**Per PyCharm:** Run-Config „Dev" starten (startet Proxy, Admin-API und Vite-Dev-Server gemeinsam).
Das Script prüft alle Ports auf Belegung, initialisiert die Datenbank und startet alle drei Dienste.
Admin-Oberfläche: `http://localhost:5173`
## Produktion (Docker)
### Docker Compose (empfohlen)
```bash
docker compose up -d
```
Zieht das Image von DockerHub, lädt Variablen aus `.env` und verwendet die lokale SQLite-Datenbank. Weitere Compose-Varianten (PostgreSQL, Ollama als Container) siehe `DOCKERHUB.md`.
### Image selbst bauen und pushen
```bash
./build_push.sh
```
Das Script zeigt den aktuellen Git-Tag, bietet an einen neuen zu setzen, baut das Image für `linux/arm64` und pusht zu `mediaeng/llmproxy`.
### Port 8001 (Admin)
Port 8001 muss exposed werden, da der Container die Admin-Oberfläche auf diesem Port ausliefert. Alle API-Endpunkte erfordern das `ADMIN_PASSWORD` — der Token ist der primäre Schutz. Optionale zusätzliche Härtung: Bindung auf `127.0.0.1`:
```yaml
ports:
- "127.0.0.1:8001:8001" # nur lokal
# oder:
- "8001:8001" # netzwerkweit, Schutz durch ADMIN_PASSWORD
```
## 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/generate \ curl -X POST http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer sk-xxxxxx" \ -H "Authorization: Bearer sk-xxxxxx" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"model":"llama3","prompt":"Say hello"}' -d '{"model":"llama3","messages":[{"role":"user","content":"Hallo"}]}'
``` ```
| 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) |
## Admin-API (Port 8001)
Alle Endpunkte erfordern einen API-Key eines Nutzers mit `is_admin=true`.
| Endpunkt | Methode | Beschreibung | | Endpunkt | Methode | Beschreibung |
|----------|---------|--------------| |----------|---------|--------------|
| `/api/users` | GET | Alle User auflisten | | `/v1/chat/completions` | POST | Chat (OpenAI-Format, Streaming + Tool-Use) |
| `/api/users` | POST | Neuen User anlegen | | `/v1/models` | GET | Modelle (OpenAI-Format) |
| `/api/api-keys` | GET | Alle API-Keys auflisten | | `/api/generate` | POST | Ollama generate (nativ) |
| `/api/api-keys` | POST | Neuen API-Key erstellen (Plaintext einmalig in Response) | | `/api/chat` | POST | Ollama chat (nativ) |
| `/api/tags` | GET | Verfügbare Modelle |
| `/api/versions` | GET | Ollama-Version |
## Admin-API (Port 8001)
Alle Endpunkte erfordern `Authorization: Bearer <ADMIN_PASSWORD>`.
| Endpunkt | Methode | Beschreibung |
|----------|---------|--------------|
| `/api/api-keys` | GET | Alle API-Keys mit Verbrauchsdaten |
| `/api/api-keys` | POST | Neuen API-Key erstellen |
| `/api/api-keys/{id}/quota` | PATCH | Limits eines Keys aktualisieren |
| `/api/api-keys/{id}/activate` | PUT | API-Key aktivieren |
| `/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/api-keys/{id}` | DELETE | API-Key löschen |
| `/api/settings` | GET/PUT | Ollama-URL und Standard-Modell |
### Quota setzen | `/api/ollama-models` | GET | Verfügbare Modelle von Ollama |
| `/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
@@ -130,28 +155,37 @@ python -m pytest tests/ -v
``` ```
llm_quota/ llm_quota/
├── backend/ ├── backend/
│ ├── main.py # Proxy-Server │ ├── main.py # Proxy-Server (Port 8000)
│ ├── admin.py # Admin-API │ ├── admin.py # Admin-API + Static-File-Serving (Port 8001)
│ ├── database.py # DB-Verbindung & Session │ ├── database.py # DB-Verbindung & Session
│ ├── models.py # SQLAlchemy-Modelle │ ├── models.py # SQLAlchemy-Modelle (APIKey, Setting, Usage)
│ ├── schemas.py # Pydantic-Schemas │ ├── schemas.py # Pydantic-Schemas
│ ├── crud.py # DB-Operationen & Token-Zählung │ ├── crud.py # DB-Operationen, Token-Zählung, Quota-Logik
│ ├── init_db.py # Tabellen anlegen │ ├── init_db.py # Tabellen anlegen & Settings seeden
│ ├── 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
│ ├── test_auth.py # Authentifizierungs-Tests │ ├── test_auth.py
│ └── test_quota.py # Quota- & Token-Tests │ └── test_quota.py
├── frontend/ ├── frontend/
│ └── src/ │ └── src/
│ ├── main.jsx │ ├── main.jsx # React-Admin-UI
│ └── styles.css │ └── styles.css
├── .gitignore ├── .idea/runConfigurations/
└── docker-compose.yml │ └── Dev.xml # PyCharm Run-Config
├── Dockerfile
├── docker-compose.yml # Produktiv-Start mit DockerHub-Image
├── docker-entrypoint.sh
├── .dockerignore
├── start.sh # Entwicklungs-Startscript
├── run_dev.py # Entwicklungs-Runner für PyCharm
├── build_push.sh # Docker-Build & Push zu DockerHub
├── DOCKERHUB.md # DockerHub-Beschreibung (deutsch)
├── DOCKERHUB.en.md # DockerHub-Beschreibung (englisch)
└── .gitignore
``` ```
## Lizenz ## Lizenz
MIT MIT
+124 -67
View File
@@ -1,115 +1,172 @@
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 User, APIKey, Quota from models import APIKey as APIKeyModel, Usage as UsageModel
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", "DELETE"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"], allow_headers=["Authorization", "Content-Type"],
) )
async def require_admin_auth(request: Request, db: Session = Depends(get_db)): def require_admin_auth(request: Request):
auth_header = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): token = auth.removeprefix("Bearer ").strip()
api_key = auth_header.replace("Bearer ", "") if not secrets.compare_digest(token, ADMIN_PASSWORD):
elif auth_header.startswith("sk-"): raise HTTPException(status_code=401, detail="Invalid admin password")
api_key = auth_header
else:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
db_key = crud.verify_api_key(db, api_key) @app.get("/api/api-keys", response_model=list[schemas.APIKeyWithUsage])
if not db_key: async def read_api_keys(
raise HTTPException(status_code=401, detail="Invalid API key") skip: int = 0, limit: int = 100,
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),
): ):
users = db.query(User).offset(skip).limit(limit).all() keys = db.query(APIKeyModel).offset(skip).limit(limit).all()
return users result = []
for key in keys:
@app.post("/api/users", response_model=schemas.User) item = schemas.APIKeyWithUsage.model_validate(key)
async def create_user( usage = db.query(UsageModel).filter(UsageModel.api_key_id == key.id).first()
user: schemas.UserCreate, if usage:
db: Session = Depends(get_db), item.tokens_used_today = usage.tokens_used_today or 0
_ = Depends(require_admin_auth) item.tokens_used_month = usage.tokens_used_month or 0
): item.requests_today = usage.requests_today or 0
db_user = crud.get_user_by_username(db, username=user.username) item.requests_month = usage.requests_month or 0
if db_user: item.daily_reset_at = usage.daily_reset_at
raise HTTPException(status_code=400, detail="Username already registered") item.monthly_reset_at = usage.monthly_reset_at
db_user = crud.get_user_by_email(db, email=user.email) result.append(item)
if db_user: return result
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_user = db.query(User).filter(User.id == api_key.user_id).first() db_key, raw_key = crud.create_api_key(
if not db_user: db,
raise HTTPException(status_code=404, detail="User not found") name=api_key.name,
db_key, raw_key = crud.create_api_key(db=db, user_id=api_key.user_id, name=api_key.name) expires_at=api_key.expires_at,
daily_tokens=api_key.daily_tokens,
monthly_tokens=api_key.monthly_tokens,
daily_requests=api_key.daily_requests,
monthly_requests=api_key.monthly_requests,
)
result = schemas.APIKeyCreated.model_validate(db_key) result = schemas.APIKeyCreated.model_validate(db_key)
result.plaintext_key = raw_key result.plaintext_key = raw_key
return result return result
@app.get("/api/api-keys", response_model=list[schemas.APIKey]) @app.patch("/api/api-keys/{api_key_id}/quota", response_model=schemas.APIKey)
async def read_api_keys( async def update_quota(
skip: int = 0, api_key_id: int,
limit: int = 100, quota: schemas.QuotaUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin_auth) _ = Depends(require_admin_auth),
): ):
api_keys = db.query(APIKey).offset(skip).limit(limit).all() db_key = db.query(APIKeyModel).filter(APIKeyModel.id == api_key_id).first()
return api_keys if not db_key:
raise HTTPException(status_code=404, detail="API key not found")
for field, value in quota.model_dump(exclude_unset=True).items():
setattr(db_key, field, value)
db.commit()
db.refresh(db_key)
return db_key
@app.put("/api/api-keys/{api_key_id}/deactivate") @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(APIKey).filter(APIKey.id == api_key_id).first() db_key = db.query(APIKeyModel).filter(APIKeyModel.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.put("/api/quotas/{user_id}", response_model=schemas.Quota) @app.put("/api/api-keys/{api_key_id}/activate")
async def update_quota( async def activate_api_key(
user_id: int, api_key_id: int,
quota: schemas.QuotaCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin_auth) _ = Depends(require_admin_auth),
): ):
db_quota = db.query(Quota).filter(Quota.user_id == user_id).first() db_key = db.query(APIKeyModel).filter(APIKeyModel.id == api_key_id).first()
if not db_quota: if not db_key:
raise HTTPException(status_code=404, detail="Quota not found") raise HTTPException(status_code=404, detail="API key not found")
for key, value in quota.model_dump(exclude_unset=True).items(): db_key.is_active = True
setattr(db_quota, key, value)
db.commit() db.commit()
db.refresh(db_quota) return {"message": "API key activated"}
return db_quota
@app.delete("/api/api-keys/{api_key_id}")
async def delete_api_key(
api_key_id: int,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
db_key = db.query(APIKeyModel).filter(APIKeyModel.id == api_key_id).first()
if not db_key:
raise HTTPException(status_code=404, detail="API key not found")
db.delete(db_key)
db.commit()
return {"message": "API key deleted"}
@app.get("/api/proxy-info")
async def get_proxy_info(_ = Depends(require_admin_auth)):
host = os.getenv("PROXY_HOST", "0.0.0.0")
port = os.getenv("PROXY_PORT", "8000")
display_host = "localhost" if host in ("0.0.0.0", "::") else host
return {"endpoint": f"http://{display_host}:{port}"}
@app.get("/api/settings", response_model=schemas.Settings)
async def read_settings(db: Session = Depends(get_db), _ = Depends(require_admin_auth)):
return schemas.Settings(
ollama_url=crud.get_setting(db, "ollama_url", "http://localhost:11434"),
default_model=crud.get_setting(db, "default_model", "llama3"),
)
@app.put("/api/settings", response_model=schemas.Settings)
async def update_settings(
settings: schemas.Settings,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
ollama_url = settings.ollama_url.rstrip('/').removesuffix('/v1')
crud.set_setting(db, "ollama_url", ollama_url)
crud.set_setting(db, "default_model", settings.default_model)
return schemas.Settings(ollama_url=ollama_url, default_model=settings.default_model)
@app.get("/api/ollama-models")
async def get_ollama_models(
url: str = None,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
ollama_url = url or crud.get_setting(db, "ollama_url", "http://localhost:11434")
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{ollama_url}/api/tags")
models = [m["name"] for m in response.json().get("models", [])]
except Exception:
models = []
return {"models": models}
# 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")
+63 -63
View File
@@ -1,116 +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, User, Quota, Usage from models import APIKey, Usage, Setting
_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 get_user_by_username(db: Session, username: str): def generate_api_key() -> 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_user(db: Session, username: str, email: str, password: str, is_admin: bool = False): def create_api_key(
db_user = User( db: Session,
username=username, name: str,
email=email, expires_at: datetime = None,
hashed_password=hash_password(password), daily_tokens: int = None,
is_admin=is_admin, monthly_tokens: int = None,
) daily_requests: int = None,
db.add(db_user) monthly_requests: int = None,
db.commit() ) -> tuple[APIKey, str]:
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),
user_id=user_id key_prefix=raw_key[:12],
expires_at=expires_at,
daily_tokens=daily_tokens,
monthly_tokens=monthly_tokens,
daily_requests=daily_requests,
monthly_requests=monthly_requests,
) )
db.add(db_key) db.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)
return db.query(APIKey).filter(APIKey.key == key_hash, APIKey.is_active == True).first() db_key = db.query(APIKey).filter(APIKey.key == key_hash, APIKey.is_active == True).first()
if db_key and db_key.expires_at:
expires = db_key.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires < datetime.now(timezone.utc):
return None
return db_key
def get_quota(db: Session, user_id: int): def check_and_increment_quota(db: Session, api_key_id: int, tokens: int = 0, requests: int = 1) -> bool:
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.user_id == user_id) .filter(Usage.api_key_id == api_key_id)
.with_for_update() .with_for_update()
.first() .first()
) )
if not usage: if not usage:
usage = Usage(user_id=user_id) usage = Usage(api_key_id=api_key_id)
db.add(usage) db.add(usage)
db.flush() db.flush()
now = datetime.now(timezone.utc) now = _now_local()
daily_reset_local = _to_local(usage.daily_reset_at)
monthly_reset_local = _to_local(usage.monthly_reset_at)
if usage.daily_reset_at.date() < now.date(): if daily_reset_local.date() < now.date():
usage.tokens_used_today = 0 usage.tokens_used_today = 0
usage.requests_today = 0 usage.requests_today = 0
usage.daily_reset_at = now usage.daily_reset_at = now
if (usage.monthly_reset_at.year, usage.monthly_reset_at.month) < (now.year, now.month): if (monthly_reset_local.year, monthly_reset_local.month) < (now.year, now.month):
usage.tokens_used_month = 0 usage.tokens_used_month = 0
usage.requests_month = 0 usage.requests_month = 0
usage.monthly_reset_at = now usage.monthly_reset_at = now
quota = get_quota(db, user_id) api_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
allowed = True allowed = True
if quota: if api_key:
if quota.daily_tokens and (usage.tokens_used_today + tokens) > quota.daily_tokens: if api_key.daily_tokens and (usage.tokens_used_today + tokens) > api_key.daily_tokens:
allowed = False allowed = False
elif quota.monthly_tokens and (usage.tokens_used_month + tokens) > quota.monthly_tokens: elif api_key.monthly_tokens and (usage.tokens_used_month + tokens) > api_key.monthly_tokens:
allowed = False allowed = False
elif quota.daily_requests and (usage.requests_today + requests) > quota.daily_requests: elif api_key.daily_requests and (usage.requests_today + requests) > api_key.daily_requests:
allowed = False allowed = False
elif quota.monthly_requests and (usage.requests_month + requests) > quota.monthly_requests: elif api_key.monthly_requests and (usage.requests_month + requests) > api_key.monthly_requests:
allowed = False allowed = False
if allowed: if allowed:
@@ -120,4 +120,4 @@ def check_and_increment_quota(db: Session, user_id: int, tokens: int = 0, reques
usage.requests_month += requests usage.requests_month += requests
db.commit() db.commit()
return allowed return allowed
+9 -10
View File
@@ -1,19 +1,18 @@
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
try: DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./test.db")
import os
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://ollama:password@localhost:5432/ollama_proxy") if "sqlite" in DATABASE_URL:
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():
+14 -15
View File
@@ -1,24 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import Base
import models
import os import os
from dotenv import load_dotenv
SQLALCHEMY_DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./test.db") load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
try: from database import Base, engine, SessionLocal
if "sqlite" in SQLALCHEMY_DATABASE_URL: import models
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) from crud import get_setting, set_setting
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()
+86 -97
View File
@@ -1,144 +1,133 @@
import time import logging
import uuid import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from fastapi import FastAPI, HTTPException, Depends, Request from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db, SessionLocal from database import get_db
import crud import crud
import httpx import httpx
import os
app = FastAPI(title="Ollama Proxy") # Rotating usage log (8 KB per file, 3 backups)
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") _log_path = Path(os.getenv("LOG_FILE", "logs/usage.log"))
_log_path.parent.mkdir(parents=True, exist_ok=True)
_handler = RotatingFileHandler(str(_log_path), maxBytes=8192, backupCount=3, encoding="utf-8")
_handler.setFormatter(logging.Formatter("%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
usage_log = logging.getLogger("proxy.usage")
usage_log.setLevel(logging.INFO)
usage_log.addHandler(_handler)
usage_log.propagate = False
async def proxy_request(url: str, method: str = "GET", json_data: dict = None, headers: dict = None): def _last_user_msg(messages: list, max_len: int = 120) -> str:
async with httpx.AsyncClient() as client: for msg in reversed(messages):
response = await client.request(method=method, url=url, json=json_data, headers=headers) if msg.get("role") == "user":
return response text = (msg.get("content") or "").replace("\n", " ").strip()
return text[:max_len] + ("…" if len(text) > max_len else "")
return ""
@app.middleware("http") async def require_api_key(request: Request, db: Session = Depends(get_db)):
async def authenticate_and_quota(request: Request, call_next):
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
api_key = auth_header.replace("Bearer ", "") api_key = auth_header[7:]
elif auth_header.startswith("sk-"): elif auth_header.startswith("sk-"):
api_key = auth_header api_key = auth_header
else: else:
return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"}) raise HTTPException(status_code=401, detail="Invalid or missing API key")
db_key = crud.verify_api_key(db, api_key)
if not db_key:
raise HTTPException(status_code=401, detail="Invalid API key")
request.state.api_key_id = db_key.id
request.state.api_key_name = db_key.name
db = SessionLocal() app = FastAPI(title="Ollama Proxy", dependencies=[Depends(require_api_key)])
try:
db_key = crud.verify_api_key(db, api_key)
if not db_key:
return JSONResponse(status_code=401, content={"detail": "Invalid API key"})
if not db_key.is_active:
return JSONResponse(status_code=403, content={"detail": "API key deactivated"})
request.state.user_id = db_key.user_id
finally:
db.close()
response = await call_next(request) async def proxy_request(url: str, method: str = "GET", json_data: dict = None):
return response async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.request(method=method, url=url, json=json_data)
return response
@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)):
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, user_id, tokens=prompt_tokens, requests=1):
if not crud.check_and_increment_quota(db, request.state.api_key_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)) prompt_preview = (body.get("prompt", "").replace("\n", " ").strip())[:120]
usage_log.info('%s | /api/generate | %s | ~%d tokens | "%s"',
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers)) request.state.api_key_name, body.get("model", "?"), prompt_tokens, prompt_preview)
response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code)
@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)):
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()
messages = body.get("messages", [])
prompt_tokens = sum(crud.count_tokens(msg.get("content") or "") for msg in messages)
prompt_tokens = sum(crud.count_tokens(msg.get("content", "")) for msg in body.get("messages", [])) if not crud.check_and_increment_quota(db, request.state.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)) usage_log.info('%s | /api/chat | %s | ~%d tokens | "%s"',
request.state.api_key_name, body.get("model", "?"), prompt_tokens, _last_user_msg(messages))
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers)) response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/api/tags") @app.get("/api/tags")
async def list_models(request: Request): async def list_models(db: Session = Depends(get_db)):
response = await proxy_request(f"{OLLAMA_URL}/api/tags", method="GET", headers=dict(request.headers)) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers)) response = await proxy_request(f"{ollama_url}/api/tags", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/api/versions") @app.get("/api/versions")
async def versions(request: Request): async def versions(db: Session = Depends(get_db)):
response = await proxy_request(f"{OLLAMA_URL}/api/versions", method="GET", headers=dict(request.headers)) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers)) response = await proxy_request(f"{ollama_url}/api/versions", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/v1/models") @app.get("/v1/models")
async def list_openai_models(request: Request): async def list_openai_models(db: Session = Depends(get_db)):
response = await proxy_request(f"{OLLAMA_URL}/api/tags", method="GET", headers=dict(request.headers)) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
ollama_models = response.json() response = await proxy_request(f"{ollama_url}/v1/models", method="GET")
openai_models = { return JSONResponse(content=response.json(), status_code=response.status_code)
"object": "list",
"data": [
{
"id": model["name"],
"object": "model",
"created": int(model["modified_at"][:10].replace("-", "")) * 1000 if "modified_at" in model else 0,
"owned_by": "ollama"
}
for model in ollama_models.get("models", [])
]
}
return JSONResponse(content=openai_models, status_code=200, headers=dict(response.headers))
@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)):
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") or "") for msg in messages)
if not crud.check_and_increment_quota(db, user_id, tokens=prompt_tokens, requests=1): if not crud.check_and_increment_quota(db, request.state.api_key_id, tokens=prompt_tokens, requests=1):
raise HTTPException(status_code=429, detail="Quota exceeded") raise HTTPException(status_code=429, detail="Quota exceeded")
ollama_body = { if "model" not in body:
"model": body.get("model", "llama3"), body = {**body, "model": default_model}
"messages": messages,
"stream": body.get("stream", False)
}
response = await proxy_request(f"{OLLAMA_URL}/api/chat", method="POST", json_data=ollama_body, headers=dict(request.headers)) model_name = body["model"]
usage_log.info('%s | /v1/chat/completions | %s | ~%d tokens | "%s"',
request.state.api_key_name, model_name, prompt_tokens, _last_user_msg(messages))
response_content = response.json().get("message", {}).get("content", "") target = f"{ollama_url}/v1/chat/completions"
completion_tokens = crud.count_tokens(response_content)
openai_response = { if body.get("stream"):
"id": f"chatcmpl-{uuid.uuid4().hex}", async def generate():
"object": "chat.completion", async with httpx.AsyncClient(timeout=300.0) as client:
"created": int(time.time()), async with client.stream("POST", target, json=body) as resp:
"model": body.get("model", "llama3"), async for chunk in resp.aiter_bytes():
"choices": [ yield chunk
{ return StreamingResponse(
"index": 0, generate(),
"message": { media_type="text/event-stream",
"role": "assistant", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
"content": response_content )
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens
}
}
return JSONResponse(content=openai_response, status_code=200, headers={"Content-Type": "application/json"}) response = await proxy_request(target, method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code)
+10 -21
View File
@@ -4,46 +4,35 @@ 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")) key_prefix = Column(String)
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)
user_id = Column(Integer, ForeignKey("users.id"), unique=True) api_key_id = Column(Integer, ForeignKey("api_keys.id"), unique=True)
tokens_used_today = Column(BigInteger, default=0) tokens_used_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)
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.25.1
pytest-cov==6.0.0
+1 -3
View File
@@ -1,5 +1,6 @@
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
@@ -9,6 +10,3 @@ 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
+40 -44
View File
@@ -2,61 +2,46 @@ from pydantic import BaseModel
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
class UserBase(BaseModel): class APIKeyCreate(BaseModel):
username: str
email: str
is_admin: bool = False
class UserCreate(UserBase):
password: str
class User(UserBase):
id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class APIKeyBase(BaseModel):
name: str name: str
expires_at: Optional[datetime] = None
class APIKeyCreate(APIKeyBase):
user_id: int
class APIKey(APIKeyBase):
id: int
key: str
user_id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class APIKeyCreated(APIKey):
plaintext_key: str
class Config:
from_attributes = True
class QuotaBase(BaseModel):
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 QuotaCreate(QuotaBase): class APIKey(BaseModel):
user_id: int
class Quota(QuotaBase):
id: int id: int
user_id: int name: str
reset_at: Optional[datetime] = None key: str
key_prefix: Optional[str] = None
is_active: bool
created_at: datetime
expires_at: Optional[datetime] = None
daily_tokens: Optional[int] = None
monthly_tokens: Optional[int] = None
daily_requests: Optional[int] = None
monthly_requests: Optional[int] = None
class Config: class Config:
from_attributes = True from_attributes = True
class APIKeyCreated(APIKey):
plaintext_key: Optional[str] = None
class Config:
from_attributes = True
class QuotaUpdate(BaseModel):
daily_tokens: Optional[int] = None
monthly_tokens: Optional[int] = None
daily_requests: Optional[int] = None
monthly_requests: Optional[int] = None
class Settings(BaseModel):
ollama_url: str
default_model: str
class UsageStats(BaseModel): class UsageStats(BaseModel):
tokens_used_today: int = 0 tokens_used_today: int = 0
tokens_used_month: int = 0 tokens_used_month: int = 0
@@ -67,3 +52,14 @@ class UsageStats(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class APIKeyWithUsage(APIKey):
tokens_used_today: int = 0
tokens_used_month: int = 0
requests_today: int = 0
requests_month: int = 0
daily_reset_at: Optional[datetime] = None
monthly_reset_at: Optional[datetime] = None
class Config:
from_attributes = True
+14 -31
View File
@@ -1,42 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from database import Base, engine, SessionLocal from database import Base, engine, SessionLocal
from models import User, APIKey, Quota, Usage from models import APIKey
from crud import create_user, create_api_key, hash_password from crud import create_api_key
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()
admin_user = db.query(User).filter(User.username == "admin").first() if not existing:
if not admin_user: _, raw_key = create_api_key(
admin_user = User( db,
username="admin", name="default",
email="admin@ollama.local", daily_tokens=1_000_000,
hashed_password=hash_password("admin123"), monthly_tokens=10_000_000,
is_active=True, daily_requests=1000,
is_admin=True, monthly_requests=10000,
) )
db.add(admin_user) print(f"API Key created: {raw_key}")
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("✗ Admin user already exists") print("Default API key already exists")
db.close() db.close()
if __name__ == "__main__": if __name__ == "__main__":
setup_admin() setup_admin()
+5 -50
View File
@@ -6,68 +6,23 @@ 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 models import User, Quota from crud import create_api_key
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,
test_user = User( monthly_tokens=10_000_000, daily_requests=1000,
username="testuser", monthly_requests=10000)
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 from database import engine, 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)
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
+77 -75
View File
@@ -5,33 +5,21 @@ 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 User, Quota, Usage from models import APIKey, Usage
from crud import check_and_increment_quota, count_tokens, hash_password from crud import check_and_increment_quota, count_tokens, create_api_key, verify_api_key
def make_user_and_quota(db, daily_tokens=None, monthly_tokens=None, def make_api_key(db, daily_tokens=None, monthly_tokens=None,
daily_requests=None, monthly_requests=None): daily_requests=None, monthly_requests=None):
user = User( db_key, _ = create_api_key(
username="quotauser", db,
email="quota@example.com", name="test-key",
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,
) )
db.add(quota) return db_key.id
db.commit()
return user.id
@pytest.fixture @pytest.fixture
@@ -64,119 +52,133 @@ 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):
user_id = make_user_and_quota(db, daily_tokens=1000) api_key_id = make_api_key(db, daily_tokens=1000)
assert check_and_increment_quota(db, user_id, tokens=100, requests=1) is True assert check_and_increment_quota(db, api_key_id, tokens=100, requests=1) is True
def test_denied_when_daily_tokens_exceeded(db): def test_denied_when_daily_tokens_exceeded(db):
user_id = make_user_and_quota(db, daily_tokens=50) api_key_id = make_api_key(db, daily_tokens=50)
assert check_and_increment_quota(db, user_id, tokens=100, requests=1) is False assert check_and_increment_quota(db, api_key_id, tokens=100, requests=1) is False
def test_denied_when_monthly_tokens_exceeded(db): def test_denied_when_monthly_tokens_exceeded(db):
user_id = make_user_and_quota(db, monthly_tokens=50) api_key_id = make_api_key(db, monthly_tokens=50)
assert check_and_increment_quota(db, user_id, tokens=100, requests=1) is False assert check_and_increment_quota(db, api_key_id, tokens=100, requests=1) is False
def test_denied_when_daily_requests_exceeded(db): def test_denied_when_daily_requests_exceeded(db):
user_id = make_user_and_quota(db, daily_requests=1) api_key_id = make_api_key(db, daily_requests=1)
check_and_increment_quota(db, user_id, tokens=0, requests=1) check_and_increment_quota(db, api_key_id, tokens=0, requests=1)
assert check_and_increment_quota(db, user_id, tokens=0, requests=1) is False assert check_and_increment_quota(db, api_key_id, tokens=0, requests=1) is False
def test_denied_when_monthly_requests_exceeded(db): def test_denied_when_monthly_requests_exceeded(db):
user_id = make_user_and_quota(db, monthly_requests=1) api_key_id = make_api_key(db, monthly_requests=1)
check_and_increment_quota(db, user_id, tokens=0, requests=1) check_and_increment_quota(db, api_key_id, tokens=0, requests=1)
assert check_and_increment_quota(db, user_id, tokens=0, requests=1) is False assert check_and_increment_quota(db, api_key_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):
user_id = make_user_and_quota(db, daily_tokens=1000, monthly_tokens=10000, api_key_id = make_api_key(db, daily_tokens=1000, monthly_tokens=10000,
daily_requests=100, monthly_requests=1000) daily_requests=100, monthly_requests=1000)
check_and_increment_quota(db, user_id, tokens=50, requests=1) check_and_increment_quota(db, api_key_id, tokens=50, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first() usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
assert usage.tokens_used_today == 50 assert usage.tokens_used_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):
user_id = make_user_and_quota(db, daily_tokens=1000) api_key_id = make_api_key(db, daily_tokens=1000)
assert db.query(Usage).filter(Usage.user_id == user_id).first() is None assert db.query(Usage).filter(Usage.api_key_id == api_key_id).first() is None
check_and_increment_quota(db, user_id, tokens=10, requests=1) check_and_increment_quota(db, api_key_id, tokens=10, requests=1)
assert db.query(Usage).filter(Usage.user_id == user_id).first() is not None assert db.query(Usage).filter(Usage.api_key_id == api_key_id).first() is not None
def test_no_quota_allows_any_request(db): def test_no_quota_allows_any_request(db):
user_id = make_user_and_quota(db) # all limits None api_key_id = make_api_key(db) # all limits None
assert check_and_increment_quota(db, user_id, tokens=999999, requests=9999) is True assert check_and_increment_quota(db, api_key_id, tokens=999999, requests=9999) is True
def test_cumulative_usage_across_calls(db): def test_cumulative_usage_across_calls(db):
user_id = make_user_and_quota(db, daily_tokens=200) api_key_id = make_api_key(db, daily_tokens=200)
check_and_increment_quota(db, user_id, tokens=100, requests=1) check_and_increment_quota(db, api_key_id, tokens=100, requests=1)
check_and_increment_quota(db, user_id, tokens=99, requests=1) check_and_increment_quota(db, api_key_id, tokens=99, requests=1)
# 199 used, 1 remaining – exactly 1 more token should pass assert check_and_increment_quota(db, api_key_id, tokens=1, requests=1) is True
assert check_and_increment_quota(db, user_id, tokens=1, requests=1) is True assert check_and_increment_quota(db, api_key_id, tokens=1, requests=1) is False
# 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):
user_id = make_user_and_quota(db, daily_tokens=100) api_key_id = make_api_key(db, daily_tokens=100)
check_and_increment_quota(db, user_id, tokens=90, requests=1) check_and_increment_quota(db, api_key_id, tokens=90, requests=1)
# Backdate daily_reset_at to yesterday 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()
# Should pass again after reset 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.user_id == user_id).first() usage = db.query(Usage).filter(Usage.api_key_id == api_key_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):
user_id = make_user_and_quota(db, daily_tokens=1000, monthly_tokens=10000) api_key_id = make_api_key(db, daily_tokens=1000, monthly_tokens=10000)
check_and_increment_quota(db, user_id, tokens=50, requests=1) check_and_increment_quota(db, api_key_id, tokens=50, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first() usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
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, user_id, tokens=50, requests=1) check_and_increment_quota(db, api_key_id, tokens=50, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first() usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
assert usage.tokens_used_today == 50 assert usage.tokens_used_today == 50
assert usage.tokens_used_month == 100 # cumulative across days assert usage.tokens_used_month == 100
def test_monthly_reset_restores_access(db): def test_monthly_reset_restores_access(db):
user_id = make_user_and_quota(db, monthly_tokens=100) api_key_id = make_api_key(db, monthly_tokens=100)
check_and_increment_quota(db, user_id, tokens=90, requests=1) check_and_increment_quota(db, api_key_id, tokens=90, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first() usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
usage.monthly_reset_at = datetime.now(timezone.utc) - timedelta(days=32) 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, api_key_id, tokens=90, requests=1) is True
usage = db.query(Usage).filter(Usage.user_id == user_id).first() usage = db.query(Usage).filter(Usage.api_key_id == api_key_id).first()
assert usage.tokens_used_month == 90 assert usage.tokens_used_month == 90
def test_failed_quota_check_still_commits_reset(db): def test_failed_quota_check_still_commits_reset(db):
user_id = make_user_and_quota(db, daily_tokens=100, daily_requests=5) api_key_id = make_api_key(db, daily_tokens=100, daily_requests=5)
check_and_increment_quota(db, user_id, tokens=80, requests=1) check_and_increment_quota(db, api_key_id, tokens=80, requests=1)
# Backdate so a reset fires, but the new request still exceeds the limit usage = db.query(Usage).filter(Usage.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)
usage.tokens_used_today = 80 usage.tokens_used_today = 80
db.commit() db.commit()
# After reset tokens_used_today = 0; 200 tokens exceeds 100 limit result = check_and_increment_quota(db, api_key_id, tokens=200, requests=1)
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.user_id == user_id).first() usage = db.query(Usage).filter(Usage.api_key_id == api_key_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
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
IMAGE=mediaeng/llmproxy
PLATFORM=linux/arm64
CURRENT=$(git describe --tags --always)
if [ -z "$CURRENT" ]; then
echo "Fehler: git describe liefert kein Ergebnis"
exit 1
fi
echo "Aktueller Tag: $CURRENT"
read -rp "Neuer Tag [${CURRENT}]: " INPUT
VERSION="${INPUT:-$CURRENT}"
if [ "$VERSION" != "$CURRENT" ]; then
git tag "$VERSION"
git push origin "$VERSION"
echo "Tag '$VERSION' gesetzt und gepusht."
fi
echo ""
echo "Image : $IMAGE"
echo "Platform: $PLATFORM"
echo "Tags : $IMAGE:$VERSION $IMAGE:latest"
echo ""
docker buildx build \
--platform "$PLATFORM" \
--push \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:latest" \
.
echo ""
echo "Fertig. Gepusht:"
echo " docker pull $IMAGE:$VERSION"
echo " docker pull $IMAGE:latest"
+11 -24
View File
@@ -1,24 +1,11 @@
backend: services:
build: llmproxy:
context: ./backend image: mediaeng/llmproxy:latest
dockerfile: Dockerfile restart: unless-stopped
ports: env_file: .env
- "8000:8000" ports:
environment: - "${PROXY_PORT:-8000}:${PROXY_PORT:-8000}"
- DATABASE_URL=postgresql://ollama:password@db:5432/ollama_proxy - "127.0.0.1:8001:8001"
- OLLAMA_URL=http://ollama:11434 volumes:
- SECRET_KEY=your-secret-key-change-me - ./backend/test.db:/app/backend/test.db
depends_on: - ./backend/logs:/app/backend/logs
- db
db:
image: postgres:16
environment:
- POSTGRES_USER=ollama
- POSTGRES_PASSWORD=password
- POSTGRES_DB=ollama_proxy
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
+19
View File
@@ -0,0 +1,19 @@
#!/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
+2116
View File
File diff suppressed because it is too large. Load diff
+457 -56
View File
@@ -1,66 +1,389 @@
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 displayKey = (prefix) => prefix ? `${prefix}••••••••` : '••••••••••••';
function App() { function authHeaders(token) {
const [users, setUsers] = useState([]); return { Authorization: `Bearer ${token}` };
const [apiKeys, setApiKeys] = useState([]); }
const [loading, setLoading] = useState(true);
const fmtK = (n) => { const k = n / 1000; return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`; };
function QuotaBar({ used, limit, isToken = false, since = null }) {
const fmt = isToken ? fmtK : (n) => n.toLocaleString('de-DE');
const sinceLabel = since
? new Date(since).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
: null;
if (limit == null) return (
<div className="quota-cell">
<span className="quota-unlimited">∞</span>
{sinceLabel && <span className="quota-since">seit {sinceLabel}</span>}
</div>
);
const pct = Math.min(100, (used / limit) * 100);
const color = pct >= 90 ? '#e74c3c' : pct >= 70 ? '#e67e22' : '#27ae60';
return (
<div className="quota-cell">
<span className="quota-label">{fmt(used)} / {fmt(limit)}</span>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${pct}%`, backgroundColor: color }} />
</div>
{sinceLabel && <span className="quota-since">seit {sinceLabel}</span>}
</div>
);
}
function Login({ onLogin }) {
const [password, setPassword] = useState('');
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { const handleSubmit = async (e) => {
Promise.all([fetchUsers(), fetchApiKeys()]).finally(() => setLoading(false)); e.preventDefault();
}, []); setError(null);
const fetchUsers = async () => {
try { try {
const res = await axios.get('/api/users'); await axios.get('/api/api-keys', { headers: authHeaders(password) });
setUsers(res.data); sessionStorage.setItem('admin_password', password);
} catch (err) { onLogin(password);
setError('Benutzer konnten nicht geladen werden.'); } catch {
setError('Ungültiges Passwort.');
} }
}; };
const fetchApiKeys = async () => {
try {
const res = await axios.get('/api/api-keys');
setApiKeys(res.data);
} catch (err) {
setError('API-Keys konnten nicht geladen werden.');
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div className="error">{error}</div>;
return ( return (
<div className="container"> <div className="container">
<h1>Ollama Proxy Admin</h1> <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 [modelsLoading, setModelsLoading] = useState(false);
const [proxyEndpoint, setProxyEndpoint] = useState(null);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
const fetchModels = async (url, currentModel) => {
setModelsLoading(true);
try {
const res = await axios.get('/api/ollama-models', {
headers: authHeaders(password),
params: url ? { url } : {},
});
const models = res.data.models;
setAvailableModels(models);
if (models.length > 0 && !models.includes(currentModel)) {
setSettings(s => ({ ...s, default_model: models[0] }));
}
} catch {
setAvailableModels([]);
} finally {
setModelsLoading(false);
}
};
useEffect(() => {
const headers = authHeaders(password);
Promise.all([
axios.get('/api/settings', { headers }),
axios.get('/api/proxy-info', { headers }),
]).then(([settingsRes, proxyRes]) => {
const s = settingsRes.data;
setSettings(s);
setProxyEndpoint(proxyRes.data.endpoint);
fetchModels(s.ollama_url, s.default_model);
}).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 })}
onBlur={(e) => fetchModels(e.target.value, settings.default_model)}
placeholder="http://localhost:11434"
required
/>
</div>
<div className="settings-row">
<label>Standard-Modell</label>
{modelsLoading ? (
<span className="settings-value">Lade Modelle…</span>
) : availableModels.length > 0 ? (
<select
value={settings.default_model}
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })}
>
{availableModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
) : (
<input
type="text"
value={settings.default_model}
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })}
placeholder="llama3"
required
/>
)}
</div>
{error && <div className="error">{error}</div>}
{saved && <div className="success">Gespeichert.</div>}
<button type="submit">Speichern</button>
</form>
</section>
);
}
function App() {
const [password, setPassword] = useState(() => sessionStorage.getItem('admin_password'));
const [apiKeys, setApiKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [newKey, setNewKey] = useState(null);
const [form, setForm] = useState(EMPTY_KEY_FORM);
const [creating, setCreating] = useState(false);
const [editKey, setEditKey] = useState(null);
const [editForm, setEditForm] = useState({});
useEffect(() => {
if (!password) { setLoading(false); return; }
fetchApiKeys().finally(() => setLoading(false));
}, [password]);
const fetchApiKeys = async () => {
try {
const res = await axios.get('/api/api-keys', { headers: authHeaders(password) });
setApiKeys(res.data);
} catch {
setError('API-Keys konnten nicht geladen werden.');
}
};
const handleCreate = async (e) => {
e.preventDefault();
setCreating(true);
try {
const payload = { name: form.name };
if (form.expires_at) payload.expires_at = new Date(form.expires_at).toISOString();
if (form.daily_tokens) payload.daily_tokens = Number(form.daily_tokens) * 1000;
if (form.monthly_tokens) payload.monthly_tokens = Number(form.monthly_tokens) * 1000;
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 handleActivate = async (id) => {
try {
await axios.put(`/api/api-keys/${id}/activate`, {}, { headers: authHeaders(password) });
await fetchApiKeys();
} catch {
setError('Fehler beim Aktivieren.');
}
};
const handleDelete = async (id, name) => {
if (!window.confirm(`API-Key "${name}" wirklich löschen?`)) return;
try {
await axios.delete(`/api/api-keys/${id}`, { headers: authHeaders(password) });
if (editKey?.id === id) setEditKey(null);
await fetchApiKeys();
} catch {
setError('Fehler beim Löschen.');
}
};
const handleEdit = (key) => {
setEditKey(key);
setEditForm({
daily_tokens: key.daily_tokens != null ? key.daily_tokens / 1000 : '',
monthly_tokens: key.monthly_tokens != null ? key.monthly_tokens / 1000 : '',
daily_requests: key.daily_requests ?? '',
monthly_requests: key.monthly_requests ?? '',
});
};
const handleSaveEdit = async (e) => {
e.preventDefault();
try {
const payload = {
daily_tokens: editForm.daily_tokens !== '' ? Number(editForm.daily_tokens) * 1000 : null,
monthly_tokens: editForm.monthly_tokens !== '' ? Number(editForm.monthly_tokens) * 1000 : null,
daily_requests: editForm.daily_requests !== '' ? Number(editForm.daily_requests) : null,
monthly_requests: editForm.monthly_requests !== '' ? Number(editForm.monthly_requests) : null,
};
await axios.patch(`/api/api-keys/${editKey.id}/quota`, payload, { headers: authHeaders(password) });
setEditKey(null);
await fetchApiKeys();
} catch {
setError('Fehler beim Speichern der Limits.');
}
};
const logout = () => {
sessionStorage.removeItem('admin_password');
setPassword(null);
};
if (!password) return <Login onLogin={setPassword} />;
if (loading) return <div>Laden...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="container">
<div className="header">
<h1>Ollama Proxy Admin</h1>
<button onClick={logout}>Abmelden</button>
</div>
<SettingsSection password={password} />
<section> <section>
<h2>Users</h2> <h2>Neuer API-Key</h2>
<table> <form onSubmit={handleCreate} className="create-form">
<thead> <div className="edit-form">
<tr> <label className="create-name">
<th>ID</th> Name
<th>Username</th> <small>&nbsp;</small>
<th>Email</th> <input
<th>Status</th> value={form.name}
</tr> onChange={(e) => setForm({ ...form, name: e.target.value })}
</thead> required
<tbody> placeholder="z. B. alice"
{users.map(user => ( />
<tr key={user.id}> </label>
<td>{user.id}</td> <label className="create-date">
<td>{user.username}</td> Ablaufdatum
<td>{user.email}</td> <small>leer = unbegrenzt</small>
<td>{user.is_active ? 'Active' : 'Inactive'}</td> <input
</tr> type="date"
))} value={form.expires_at}
</tbody> onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
</table> />
</label>
<div className="create-btn-wrap">
<button type="submit" disabled={creating} className="btn-save">Erstellen</button>
</div>
</div>
<div className="edit-form">
<label>
Tokens/Tag (k)
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
placeholder="∞"
value={form.daily_tokens}
onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })}
/>
</label>
<label>
Tokens/Monat (k)
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
placeholder="∞"
value={form.monthly_tokens}
onChange={(e) => setForm({ ...form, monthly_tokens: e.target.value })}
/>
</label>
<label>
Requests/Tag
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
placeholder="∞"
value={form.daily_requests}
onChange={(e) => setForm({ ...form, daily_requests: e.target.value })}
/>
</label>
<label>
Requests/Monat
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
placeholder="∞"
value={form.monthly_requests}
onChange={(e) => setForm({ ...form, monthly_requests: e.target.value })}
/>
</label>
</div>
</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>
@@ -68,28 +391,106 @@ function App() {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th></th>
<th>Name</th> <th>Name</th>
<th>Key</th> <th>Key</th>
<th>User</th> <th>Läuft ab</th>
<th>Status</th> <th className="th-quota">Tokens/Tag</th>
<th className="th-quota">Tokens/Monat</th>
<th className="th-quota">Req/Tag</th>
<th className="th-quota">Req/Monat</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{apiKeys.map(key => ( {apiKeys.map(key => (
<tr key={key.id}> <tr key={key.id} className={editKey?.id === key.id ? 'row-editing' : ''}>
<td>{key.id}</td> <td className="td-status">
<span className={key.is_active ? 'status-active' : 'status-inactive'} title={key.is_active ? 'Aktiv' : 'Inaktiv'}>●</span>
</td>
<td>{key.name}</td> <td>{key.name}</td>
<td>{maskKey(key.key)}</td> <td>{displayKey(key.key_prefix)}</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><QuotaBar used={key.tokens_used_today} limit={key.daily_tokens} isToken since={key.daily_reset_at} /></td>
<td><QuotaBar used={key.tokens_used_month} limit={key.monthly_tokens} isToken since={key.monthly_reset_at} /></td>
<td><QuotaBar used={key.requests_today} limit={key.daily_requests} since={key.daily_reset_at} /></td>
<td><QuotaBar used={key.requests_month} limit={key.monthly_requests} since={key.monthly_reset_at} /></td>
<td className="action-cell">
<button className="btn-icon btn-icon-edit" data-tooltip="Bearbeiten" onClick={() => handleEdit(key)}>✏</button>
{key.is_active ? (
<button className="btn-icon btn-icon-warn" data-tooltip="Deaktivieren" onClick={() => handleDeactivate(key.id)}>⏸</button>
) : (
<button className="btn-icon btn-icon-ok" data-tooltip="Aktivieren" onClick={() => handleActivate(key.id)}>▶</button>
)}
<button className="btn-icon btn-icon-danger" data-tooltip="Löschen" onClick={() => handleDelete(key.id, key.name)}>✕</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{editKey && (
<div className="edit-section">
<h3>Limits bearbeiten: <em>{editKey.name}</em></h3>
<form onSubmit={handleSaveEdit} className="edit-form">
<label>
Tokens/Tag (k)
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
value={editForm.daily_tokens}
onChange={(e) => setEditForm({ ...editForm, daily_tokens: e.target.value })}
placeholder="∞"
/>
</label>
<label>
Tokens/Monat (k)
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
value={editForm.monthly_tokens}
onChange={(e) => setEditForm({ ...editForm, monthly_tokens: e.target.value })}
placeholder="∞"
/>
</label>
<label>
Requests/Tag
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
value={editForm.daily_requests}
onChange={(e) => setEditForm({ ...editForm, daily_requests: e.target.value })}
placeholder="∞"
/>
</label>
<label>
Requests/Monat
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
value={editForm.monthly_requests}
onChange={(e) => setEditForm({ ...editForm, monthly_requests: e.target.value })}
placeholder="∞"
/>
</label>
<div className="edit-actions">
<button type="submit" className="btn-save">Speichern</button>
<button type="button" className="btn-cancel" onClick={() => setEditKey(null)}>Abbrechen</button>
</div>
</form>
</div>
)}
</section> </section>
</div> </div>
); );
} }
export default App; ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+378 -4
View File
@@ -44,12 +44,386 @@ 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-direction: column;
gap: 12px;
}
.create-form .edit-form {
margin-bottom: 0;
}
.create-name {
flex: 0 0 auto !important;
}
.create-name input {
width: 200px;
}
.create-date {
flex: 0 0 auto !important;
}
.create-date input[type="date"] {
width: 150px;
}
.create-btn-wrap {
flex: 0 0 auto;
display: flex;
align-items: flex-end;
}
.create-form .btn-save: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;
}
.settings-value {
flex: 1;
font-size: 14px;
color: #2c3e50;
}
.settings-value small {
margin-left: 8px;
color: #999;
font-size: 12px;
}
/* Quota progress bars */
.th-quota {
width: 90px;
}
.quota-cell {
width: 100%;
}
.quota-label {
font-size: 12px;
color: #555;
white-space: nowrap;
}
.quota-unlimited {
color: #aaa;
font-size: 14px;
}
.quota-since {
display: block;
font-size: 10px;
color: #aaa;
margin-top: 2px;
}
.progress-bar {
height: 4px;
background: #e2e8f0;
border-radius: 2px;
margin-top: 4px;
}
.progress-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
/* Edit form */
.td-status {
width: 20px;
text-align: center;
}
.status-active { color: #27ae60; font-size: 12px; }
.status-inactive { color: #bdc3c7; font-size: 12px; }
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
margin-right: 2px;
border-radius: 3px;
line-height: 1;
position: relative;
}
.btn-icon:hover {
background: rgba(0, 0, 0, 0.06);
}
.btn-icon-edit { color: #2980b9; }
.btn-icon-warn { color: #e67e22; }
.btn-icon-ok { color: #27ae60; }
.btn-icon-danger { color: #e74c3c; }
.btn-icon::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #2c3e50;
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
font-weight: 400;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 1000;
}
.btn-icon::before {
content: '';
position: absolute;
bottom: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #2c3e50;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 1000;
}
.btn-icon:hover::after,
.btn-icon:hover::before {
opacity: 1;
}
.action-cell {
white-space: nowrap;
position: relative;
overflow: visible;
}
.row-editing {
background: #eaf4fb !important;
}
.edit-section {
margin-top: 20px;
padding: 16px 20px;
background: #f0f7ff;
border: 1px solid #3498db;
border-radius: 6px;
}
.edit-section h3 {
margin: 0 0 14px;
font-size: 15px;
color: #2c3e50;
font-weight: 600; font-weight: 600;
} }
.status-inactive { .edit-section h3 em {
color: #e74c3c; font-style: normal;
font-weight: 600; color: #2980b9;
}
.edit-form {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: flex-end;
}
.edit-form label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 13px;
font-weight: 500;
color: #34495e;
}
.edit-form label small {
font-weight: 400;
color: #999;
font-size: 11px;
}
.edit-form input {
padding: 6px 8px;
border: 1px solid #bdc3c7;
border-radius: 4px;
font-size: 14px;
width: 130px;
}
.edit-actions {
display: flex;
gap: 8px;
padding-bottom: 2px;
}
.btn-save {
padding: 6px 18px;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.btn-save:hover {
background: #219a52;
}
.btn-cancel {
padding: 6px 18px;
background: #95a5a6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.btn-cancel:hover {
background: #7f8c8d;
} }
+6
View File
@@ -3,8 +3,14 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
clearScreen: false,
server: { server: {
open: true,
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',
}, },
}, },
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Dev runner: startet Proxy, Admin-API und Frontend gemeinsam."""
import os
import sys
import signal
import subprocess
import threading
from pathlib import Path
def load_env(path: Path):
if not path.exists():
return
for line in path.read_text(encoding='utf-8').splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, _, val = line.partition('=')
os.environ.setdefault(key.strip(), val.strip().strip('"\''))
def pipe_output(proc: subprocess.Popen, label: str, color: str):
reset = '\033[0m'
prefix = f'\033[{color}m[{label}]{reset} '
for raw in iter(proc.stdout.readline, b''):
sys.stdout.write(prefix + raw.decode(errors='replace'))
sys.stdout.flush()
def main():
root = Path(__file__).parent
load_env(root / '.env')
if not os.environ.get('ADMIN_PASSWORD'):
print('Fehler: ADMIN_PASSWORD nicht gesetzt (siehe .env)')
sys.exit(1)
python = root / '.venv' / 'bin' / 'python'
backend = root / 'backend'
frontend = root / 'frontend'
proxy_host = os.environ.get('PROXY_HOST', '0.0.0.0')
proxy_port = os.environ.get('PROXY_PORT', '8000')
admin_port = os.environ.get('ADMIN_PORT', '8001')
print('Initialisiere Datenbank...')
subprocess.run([str(python), 'init_db.py'], cwd=backend, check=True)
print(f'Starte Proxy → http://{proxy_host}:{proxy_port}')
print(f'Starte Admin-API → http://127.0.0.1:{admin_port}')
print('Starte Frontend → http://localhost:5173')
env = {**os.environ, 'PYTHONUNBUFFERED': '1'}
procs = [
(subprocess.Popen(
[str(python), '-m', 'uvicorn', 'main:app', '--reload',
'--host', proxy_host, '--port', proxy_port],
cwd=backend, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env,
), 'Proxy ', '34'), # blau
(subprocess.Popen(
[str(python), '-m', 'uvicorn', 'admin:app', '--reload',
'--host', '127.0.0.1', '--port', admin_port],
cwd=backend, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env,
), 'Admin ', '33'), # gelb
(subprocess.Popen(
['npm', 'run', 'dev'],
cwd=frontend, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
), 'Frontend', '32'), # grün
]
for proc, label, color in procs:
threading.Thread(target=pipe_output, args=(proc, label, color), daemon=True).start()
def stop(*_):
for p, _, _ in procs:
p.terminate()
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
for p, _, _ in procs:
p.wait()
if __name__ == '__main__':
main()
Executable
+81
View File
@@ -0,0 +1,81 @@
#!/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