26 Commits
Author SHA1 Message Date
Oliver Hofmann 70fd61608b Log actual tokens and elapsed time for all endpoints incl. streaming
For streaming /v1/chat/completions: inject stream_options.include_usage,
parse usage from SSE chunks, log actual ↑↓ tokens and wall time in the
generator's finally block. Add elapsed time to all second log entries.
2026-05-08 09:47:32 +02:00
Oliver Hofmann 07f6fec4bf Show app version in admin UI and /version endpoint
Embed APP_VERSION build arg in Docker image (default: dev).
build_push.sh passes the git tag as build arg. Proxy exposes
GET /version, admin UI shows it as read-only field in settings.
2026-05-08 09:30:23 +02:00
Oliver Hofmann 6761a73364 Add /api/ps example to Kurzanleitung 2026-05-08 09:25:44 +02:00
Oliver Hofmann 0d1ce96c99 Expose /api/ps to show currently loaded model 2026-05-08 09:22:17 +02:00
Oliver Hofmann b16b3af44d Mention VPN as alias for Intranet in Kurzanleitung 2026-05-08 09:17:08 +02:00
Oliver Hofmann fdbe0a74e8 Exclude .tex and .pdf from Docker build context 2026-05-08 08:41:49 +02:00
Oliver Hofmann 0154c89c6b Improve Python examples and opencode description in Kurzanleitung
Split second example into focused model listing only, remove repeated
prompt code. Replace TUI with 'interaktive Terminal-Oberfläche'.
2026-05-08 08:40:25 +02:00
Oliver Hofmann e3dbed9f5e Ignore generated KURZANLEITUNG.tex and .pdf 2026-05-08 08:15:07 +02:00
Oliver Hofmann 5b37718120 Fix list indentation in KURZANLEITUNG.md 2026-05-08 08:07:02 +02:00
Oliver Hofmann f823e7d314 Return 422 when model field is missing and no force_model is set
Prevents an opaque Ollama error from reaching the client by failing fast
with a clear message before the request is forwarded.
2026-05-08 08:04:52 +02:00
Oliver Hofmann 34b108f4df Replace default_model with force_model (model lock)
Removes DEFAULT_MODEL in favour of a force_model setting configurable
via the admin UI. When set, every proxy request's model field is
overridden, preventing uncoordinated model switches during lab sessions.
Updates schemas, admin API, all three proxy endpoints, frontend,
init_db, and docs (README, DOCKERHUB, KURZANLEITUNG).
2026-05-08 08:02:16 +02:00
Oliver Hofmann cced65693c Log actual Ollama token counts and add user guide
Add a second usage log line after each proxy response with actual ↑prompt ↓completion
token counts from Ollama (prompt_eval_count/eval_count for native endpoints,
usage object for OpenAI endpoint). Also adds KURZANLEITUNG.md for students and
colleagues covering API access, model selection, Python examples, opencode setup,
and quota/admin information.
2026-05-08 07:21:36 +02:00
Oliver Hofmann 256bafe30d Explain network_mode: host motivation in README 2026-05-07 16:14:44 +02:00
Oliver Hofmann 555d9899fe Fix build_push.sh tag detection 2026-05-07 16:11:50 +02:00
Oliver Hofmann 31504d1a5b Remove DOCKERHUB.md reference from README 2026-05-07 16:09:22 +02:00
Oliver Hofmann 2e7b13227d Make ADMIN_HOST consistent across dev and prod 2026-05-07 16:08:05 +02:00
Oliver Hofmann 5469981eb5 Add ADMIN_HOST to env tables and .env example 2026-05-07 16:04:23 +02:00
Oliver Hofmann a1e293b1d7 Add ADMIN_HOST env var, restructure docs
- docker-entrypoint.sh: Admin-API bindet auf ADMIN_HOST (default 0.0.0.0)
  statt hardcoded 0.0.0.0 — ermöglicht Einschränkung auf 127.0.0.1
- README: Zweck-Beschreibung, HTTPS-Reverse-Proxy-Abschnitt (Caddy/Nginx),
  Port-8001-Abschnitt korrigiert (Docker-Port-Mapping greift bei
  network_mode: host nicht), ADMIN_HOST in Konfig-Tabelle ergänzt
- DOCKERHUB.md / DOCKERHUB.en.md: Auf drei Szenarien reduziert
  (network_mode: host, Ollama als Container + SQLite/PostgreSQL);
  host.docker.internal-Varianten entfernt
- review_priorities.md: gelöscht (alle Punkte behoben)
2026-05-07 16:03:03 +02:00
hofmannol 9f92c09586 Add container_name, remove ineffective extra_hosts with network_mode host 2026-05-07 15:09:04 +02:00
hofmannol 3974010156 Use network_mode: host so container can reach Ollama on 127.0.0.1 2026-05-07 13:58:26 +02:00
hofmannol 8d3f9a7661 Fix OpenAI array content, add error logging, Ollama reachability warning
- Normalize OpenAI array-format content to string to fix connection reset
- Add error.log with rotating handler for proxy and stream errors
- Add global unhandled exception handler returning JSON 500
- Write OLLAMA_URL/DEFAULT_MODEL env vars to DB on startup (reset on restart)
- Add extra_hosts to docker-compose.yml for host.docker.internal on Linux
- Show warning in admin UI when Ollama URL is unreachable
- Return reachable: true/false from /api/ollama-models endpoint
2026-05-07 11:43:17 +02:00
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
19 changed files with 600 additions and 272 deletions

No files matched your search

+2
View File
@@ -42,6 +42,8 @@ docker-compose.yml
# Docs # Docs
*.md *.md
*.tex
*.pdf
# Dev & build scripts # Dev & build scripts
run_dev.py run_dev.py
+4
View File
@@ -27,3 +27,7 @@ frontend/dist/
# Misc # Misc
config.json config.json
# Generated documents
KURZANLEITUNG.tex
KURZANLEITUNG.pdf
+20 -71
View File
@@ -2,13 +2,12 @@
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. 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 ## Features
- OpenAI-compatible endpoint (`/v1/chat/completions`, `/v1/models`) - OpenAI-compatible endpoint (`/v1/chat/completions`, `/v1/models`)
- API key management with daily and monthly token/request limits - API key management with daily and monthly token/request limits
- Web-based admin interface (port 8001) - Web-based admin interface (port 8001)
- Model lock: enforces a specific model for all requests (useful for courses and lab sessions)
- Streaming support (Server-Sent Events) - Streaming support (Server-Sent Events)
- Tool use / function calling passthrough - Tool use / function calling passthrough
- Rotating usage logs - Rotating usage logs
@@ -19,7 +18,9 @@ Ollama does not need to run on the same host — `OLLAMA_URL` can point to any r
| Port | Service | | Port | Service |
|------|---------| |------|---------|
| `8000` | Proxy endpoint (OpenAI API) | | `8000` | Proxy endpoint (OpenAI API) |
| `8001` | Admin API + web interface (do not expose) | | `8001` | Admin API + web interface |
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.
## Environment Variables ## Environment Variables
@@ -27,86 +28,43 @@ Ollama does not need to run on the same host — `OLLAMA_URL` can point to any r
|----------|---------|-------------| |----------|---------|-------------|
| `ADMIN_PASSWORD` | – | **Required.** Password for the admin interface | | `ADMIN_PASSWORD` | – | **Required.** Password for the admin interface |
| `OLLAMA_URL` | `http://localhost:11434` | URL of the Ollama server (without `/v1` suffix) | | `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) | | `DATABASE_URL` | `sqlite:///./test.db` | Database connection string (SQLite or PostgreSQL) |
| `PROXY_HOST` | `0.0.0.0` | Proxy bind address | | `PROXY_HOST` | `0.0.0.0` | Proxy bind address |
| `PROXY_PORT` | `8000` | Proxy port | | `PROXY_PORT` | `8000` | Proxy port |
| `ADMIN_HOST` | `0.0.0.0` | Admin API bind address (`127.0.0.1` to restrict to local access) |
| `ADMIN_PORT` | `8001` | Admin API port | | `ADMIN_PORT` | `8001` | Admin API port |
| `APP_TZ` | `Europe/Berlin` | Timezone for daily/monthly quota resets | | `APP_TZ` | `Europe/Berlin` | Timezone for daily/monthly quota resets |
| `LOG_FILE` | `logs/usage.log` | Path of the rotating usage log file | | `LOG_FILE` | `logs/usage.log` | Path of the rotating usage log file |
## Docker Compose – External Ollama, SQLite ## Docker Compose – Ollama on the Host (Linux, recommended)
Use this when Ollama runs outside of Docker — on the Docker host or any other reachable server. Adjust `OLLAMA_URL` accordingly. `network_mode: host` gives the container direct access to the host network stack. Ollama runs on the host and is reachable at `localhost:11434` — not visible from outside. The proxy and admin interface are available directly on host ports 8000 and 8001.
```yaml ```yaml
services: services:
llmproxy: llmproxy:
image: mediaeng/llmproxy:latest image: mediaeng/llmproxy:latest
container_name: llmproxy
restart: unless-stopped restart: unless-stopped
ports: network_mode: host
- "8000:8000" env_file: .env
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # or http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes: volumes:
- llmproxy-data:/app/backend - 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: volumes:
llmproxy-data: llmproxy-data:
``` ```
## Docker Compose – External Ollama, PostgreSQL `.env`:
```env
```yaml ADMIN_PASSWORD=changeme
services: OLLAMA_URL=http://localhost:11434
llmproxy: APP_TZ=Europe/Berlin
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
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 ## Docker Compose – Ollama as Container, SQLite
Ollama and llmproxy run together in Docker, data persisted in a volume. Ollama and llmproxy run together in Docker. Ollama is not exposed externally.
```yaml ```yaml
services: services:
@@ -115,10 +73,11 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8000:8000" - "8000:8000"
- "8001:8001"
environment: environment:
ADMIN_PASSWORD: changeme ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434 OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin APP_TZ: Europe/Berlin
volumes: volumes:
- llmproxy-data:/app/backend - llmproxy-data:/app/backend
@@ -147,10 +106,11 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8000:8000" - "8000:8000"
- "8001:8001"
environment: environment:
ADMIN_PASSWORD: changeme ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434 OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
depends_on: depends_on:
@@ -185,17 +145,6 @@ volumes:
ollama-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 ## Client Configuration
Configure the proxy as an OpenAI-compatible endpoint: Configure the proxy as an OpenAI-compatible endpoint:
+20 -71
View File
@@ -2,13 +2,12 @@
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. 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 ## Funktionen
- OpenAI-kompatibler Endpunkt (`/v1/chat/completions`, `/v1/models`) - OpenAI-kompatibler Endpunkt (`/v1/chat/completions`, `/v1/models`)
- API-Key-Verwaltung mit tages- und monatlichen Token-/Request-Limits - API-Key-Verwaltung mit tages- und monatlichen Token-/Request-Limits
- Web-basierte Admin-Oberfläche (Port 8001) - Web-basierte Admin-Oberfläche (Port 8001)
- Modell-Lock: erzwingt ein bestimmtes Modell für alle Requests (nützlich für Praktika/Kurse)
- Streaming-Support (Server-Sent Events) - Streaming-Support (Server-Sent Events)
- Tool-Use / Function Calling wird durchgereicht - Tool-Use / Function Calling wird durchgereicht
- Rotierende Nutzungs-Logs - Rotierende Nutzungs-Logs
@@ -19,7 +18,9 @@ Ollama muss dabei nicht auf demselben Host laufen — `OLLAMA_URL` kann auf jede
| Port | Dienst | | Port | Dienst |
|------|--------| |------|--------|
| `8000` | Proxy-Endpunkt (OpenAI-API) | | `8000` | Proxy-Endpunkt (OpenAI-API) |
| `8001` | Admin-API + Web-Oberfläche (nicht exponieren) | | `8001` | Admin-API + Web-Oberfläche |
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.
## Umgebungsvariablen ## Umgebungsvariablen
@@ -27,86 +28,43 @@ Ollama muss dabei nicht auf demselben Host laufen — `OLLAMA_URL` kann auf jede
|----------|----------|--------------| |----------|----------|--------------|
| `ADMIN_PASSWORD` | – | **Pflicht.** Passwort für die Admin-Oberfläche | | `ADMIN_PASSWORD` | – | **Pflicht.** Passwort für die Admin-Oberfläche |
| `OLLAMA_URL` | `http://localhost:11434` | URL des Ollama-Servers (ohne `/v1`-Suffix) | | `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) | | `DATABASE_URL` | `sqlite:///./test.db` | Datenbank-Verbindungsstring (SQLite oder PostgreSQL) |
| `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxy | | `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxy |
| `PROXY_PORT` | `8000` | Port des Proxy | | `PROXY_PORT` | `8000` | Port des Proxy |
| `ADMIN_HOST` | `0.0.0.0` | Bind-Adresse der Admin-API (`127.0.0.1` für lokalen Zugriff) |
| `ADMIN_PORT` | `8001` | Port der Admin-API | | `ADMIN_PORT` | `8001` | Port der Admin-API |
| `APP_TZ` | `Europe/Berlin` | Zeitzone für Tages-/Monats-Reset der Quoten | | `APP_TZ` | `Europe/Berlin` | Zeitzone für Tages-/Monats-Reset der Quoten |
| `LOG_FILE` | `logs/usage.log` | Pfad der rotierenden Nutzungs-Logdatei | | `LOG_FILE` | `logs/usage.log` | Pfad der rotierenden Nutzungs-Logdatei |
## Docker Compose – Ollama extern, SQLite ## Docker Compose – Ollama auf dem Host (Linux, empfohlen)
Wenn Ollama außerhalb von Docker läuft — auf dem Docker-Host oder einem anderen erreichbaren Server. `OLLAMA_URL` entsprechend anpassen. `network_mode: host` gibt dem Container direkten Zugriff auf das Host-Netzwerk. Ollama läuft auf dem Host und ist über `localhost:11434` erreichbar — nach außen nicht sichtbar. Proxy und Admin-Oberfläche sind direkt auf den Host-Ports 8000 und 8001 verfügbar.
```yaml ```yaml
services: services:
llmproxy: llmproxy:
image: mediaeng/llmproxy:latest image: mediaeng/llmproxy:latest
container_name: llmproxy
restart: unless-stopped restart: unless-stopped
ports: network_mode: host
- "8000:8000" env_file: .env
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # oder http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes: volumes:
- llmproxy-data:/app/backend - 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: volumes:
llmproxy-data: llmproxy-data:
``` ```
## Docker Compose – Ollama extern, PostgreSQL `.env`:
```env
```yaml ADMIN_PASSWORD=changeme
services: OLLAMA_URL=http://localhost:11434
llmproxy: APP_TZ=Europe/Berlin
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
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 ## Docker Compose – Ollama als Container, SQLite
Ollama und llmproxy laufen gemeinsam in Docker, Daten in einem Volume. Ollama und llmproxy laufen gemeinsam in Docker. Ollama ist nicht nach außen exposed.
```yaml ```yaml
services: services:
@@ -115,10 +73,11 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8000:8000" - "8000:8000"
- "8001:8001"
environment: environment:
ADMIN_PASSWORD: changeme ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434 OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin APP_TZ: Europe/Berlin
volumes: volumes:
- llmproxy-data:/app/backend - llmproxy-data:/app/backend
@@ -147,10 +106,11 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8000:8000" - "8000:8000"
- "8001:8001"
environment: environment:
ADMIN_PASSWORD: changeme ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434 OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
depends_on: depends_on:
@@ -185,17 +145,6 @@ volumes:
ollama-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 ## Client-Konfiguration
Den Proxy als OpenAI-kompatibler Endpunkt konfigurieren: Den Proxy als OpenAI-kompatibler Endpunkt konfigurieren:
+2
View File
@@ -6,6 +6,8 @@ COPY frontend/ frontend/
RUN npm run build --prefix frontend RUN npm run build --prefix frontend
FROM python:3.12-slim FROM python:3.12-slim
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
WORKDIR /app WORKDIR /app
COPY backend/requirements.txt . COPY backend/requirements.txt .
+183
View File
@@ -0,0 +1,183 @@
# LLM-Dienst – Kurzanleitung
## Worum geht es?
Der Dienst stellt **große Sprachmodelle (LLMs)** über eine einfache HTTP-API bereit, die direkt aus Python-Skripten, Jupyter-Notebooks oder eigenen Anwendungen angesprochen werden kann. Die Modelle laufen lokal auf einem GPU-Server im Intranet – ohne Datenübertragung nach außen und ohne Cloud-Kosten.
Typische Anwendungsfälle:
- Texte zusammenfassen, übersetzen oder umformulieren
- KI-gestütztes Coding (z.B. mit **[opencode](https://opencode.ai)**)
- Experimente mit Prompt-Engineering und LLM-Integration in eigene Projekte
---
## Zugang
Der Dienst ist **nur im Intranet (VPN)** erreichbar.
| | |
|---|---|
| **API-Endpunkt** | `http://141.75.33.244:8000` |
| **Authentifizierung** | API-Key erforderlich (per E-Mail beim Admin anfragen) |
---
## Verfügbare Modelle
| Modell | Größe | Hinweis |
|---|---|---|
| `gemma4:31b` | 19 GB | kompakt, schnell |
| `gpt-oss:20b` | 13 GB | kompakt, schnell |
| `gpt-oss:120b` | 65 GB | sehr leistungsfähig |
| `qwen3.5:122b` | 81 GB | sehr leistungsfähig |
| `qwen3-coder-next:q8_0` | 84 GB | speziell für Code |
> **Wichtig:** Es kann immer nur **ein Modell gleichzeitig** im GPU-Speicher geladen sein.
> Wechselt jemand das Modell, muss das vorherige entladen und das neue geladen werden –
> das kann **mehrere Minuten** dauern. Der erste Prompt nach einem Modellwechsel ist
> deshalb deutlich langsamer. Danach bleibt das Modell einige Zeit geladen.
---
## Python-Beispiel – Einfacher Prompt
Das API folgt dem **OpenAI-Standard**, d.h. die `openai`-Bibliothek kann direkt verwendet werden.
```bash
pip install openai
```
```python
from openai import OpenAI
API_KEY = "sk-..." # euren API-Key eintragen
BASE_URL = "http://141.75.33.244:8000/v1"
MODEL = "gemma4:31b" # Modell nach Bedarf wählen
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "user", "content": "Erkläre den Unterschied zwischen L1- und L2-Regularisierung."}
]
)
print(response.choices[0].message.content)
```
---
## Python-Beispiel – Verfügbare Modelle abfragen
```python
from openai import OpenAI
API_KEY = "sk-..."
BASE_URL = "http://141.75.33.244:8000/v1"
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
models = client.models.list()
for m in models.data:
print(m.id)
```
---
## Aktuell geladenes Modell abfragen
Da immer nur ein Modell gleichzeitig im Speicher sein kann, lässt sich mit folgendem Aufruf prüfen, welches Modell gerade aktiv ist:
```python
import httpx
r = httpx.get(
"http://141.75.33.244:8000/api/ps",
headers={"Authorization": "Bearer sk-..."}
)
print(r.json())
```
Die Antwort enthält Modellname, Größe und wie lange das Modell noch im Speicher bleibt.
---
## Empfehlungen zur Nutzung
- **Kleines Modell zuerst** (`gemma4:31b` oder `gpt-oss:20b`) – viel schneller, für viele Aufgaben ausreichend.
- **Großes Modell** nur bei komplexen Aufgaben (`qwen3.5:122b`, `gpt-oss:120b`).
- **Code-Aufgaben**: `qwen3-coder-next:q8_0` ist speziell dafür optimiert.
- Wenn möglich, **dasselbe Modell wie andere Nutzer** verwenden, um häufige Modellwechsel zu vermeiden.
---
## Quotas
Je nach API-Key können folgende Limits konfiguriert sein:
- Maximale **Anfragen pro Tag / Monat**
- Maximale **Tokens pro Tag / Monat**
Bei Überschreitung gibt die API den Statuscode `429 Too Many Requests` zurück.
---
## Coding-Assistent: opencode
[opencode](https://opencode.ai) ist ein terminal-basierter KI-Coding-Agent (ähnlich Claude Code), der OpenAI-kompatible APIs unterstützt und damit direkt auf den Intranet-Dienst zeigen kann.
### Installation
```bash
npm install -g opencode-ai
# oder
curl -fsSL https://opencode.ai/install | bash
```
### Konfiguration
Konfigurationsdatei anlegen unter `~/.config/opencode/config.json`:
```json
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"apiKey": "sk-...",
"baseURL": "http://141.75.33.244:8000/v1"
}
},
"model": "openai/qwen3-coder-next:q8_0"
}
```
Für Code-Aufgaben empfiehlt sich `qwen3-coder-next:q8_0`, für allgemeine Aufgaben `gemma4:31b` oder `gpt-oss:20b`.
### Starten
```bash
opencode
```
opencode öffnet eine interaktive Terminal-Oberfläche und kann dann im Projektverzeichnis eingesetzt werden – Dateien lesen, Code generieren, Refactoring vorschlagen usw.
---
## Administration (nur für Admins)
Das Web-Interface zur Verwaltung von API-Keys und Quotas ist erreichbar unter:
**`http://141.75.33.244:8001`**
Dort können API-Keys angelegt, deaktiviert und mit Quotas versehen werden.
### Modell-Lock für Praktika
Unter **Einstellungen → Aktives Modell (Lock)** kann ein Modell fest vorgegeben werden. Ist ein Lock gesetzt, wird das `model`-Feld in jedem Request durch dieses Modell ersetzt – unabhängig davon, was der Client schickt. Das verhindert unkoordinierte Modellwechsel während einer Veranstaltung, die alle Teilnehmenden durch lange Ladezeiten ausbremsen würden.
Typischer Ablauf für ein Praktikum:
1. Vor der Veranstaltung: passendes Modell in Ollama laden
2. Lock in der Admin-Oberfläche aktivieren
3. Nach der Veranstaltung: Lock wieder deaktivieren (Feld leeren)
+115 -69
View File
@@ -1,6 +1,6 @@
# Ollama Proxy mit API-Keys und Quotas # Ollama Proxy mit API-Keys und Quotas
Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung, Quota-Management und Web-Admin-Oberfläche. Ollama bietet von sich aus keine Authentifizierung — wer die API erreicht, kann sie nutzen. Dieses Projekt löst das Problem: Ollama bleibt an `localhost` gebunden und ist von außen nicht erreichbar. Vorgeschaltet läuft ein Proxy (Port 8000), der jeden Request auf einen gültigen API-Key prüft und optional Token- sowie Request-Quoten pro Key durchsetzt. Eine Web-Admin-Oberfläche (Port 8001) erlaubt das Verwalten von Keys, Quoten und Ollama-Einstellungen.
## Features ## Features
@@ -8,30 +8,33 @@ Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung, Quota-Management un
- Optionales Ablaufdatum pro API-Key - Optionales Ablaufdatum pro API-Key
- Quota-Management mit getrennten Tages- und Monatslimits (Tokens & Requests) - Quota-Management mit getrennten Tages- und Monatslimits (Tokens & Requests)
- Token-Zählung via tiktoken, Reset-Grenzen in der Zeitzone Europe/Berlin - Token-Zählung via tiktoken, Reset-Grenzen in der Zeitzone Europe/Berlin
- Web-Admin-Oberfläche (API-Keys verwalten, Ollama-Einstellungen, Proxy-Info) - Web-Admin-Oberfläche (API-Keys verwalten, Ollama-Einstellungen, Verbrauchsanzeige)
- OpenAI-kompatibler `/v1/chat/completions`-Endpunkt - OpenAI-kompatibler `/v1/chat/completions`-Endpunkt mit Streaming und Tool-Use
- Rotierende Nutzungs-Logs
- SQLite (Standard) oder PostgreSQL
- Docker-Image auf DockerHub: `mediaeng/llmproxy`
## Sicherheit ## Sicherheit
- Admin-Oberfläche passwortgeschützt (`ADMIN_PASSWORD`) - Admin-Oberfläche passwortgeschützt (`ADMIN_PASSWORD`) — alle API-Endpunkte erfordern den Token
- Admin-API bindet lokal auf `127.0.0.1` (nicht von außen erreichbar)
- API-Keys als SHA-256-Hash in der DB — Plaintext nur einmalig bei Erstellung - API-Keys als SHA-256-Hash in der DB — Plaintext nur einmalig bei Erstellung
- Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race) - Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race)
- CORS-Origins konfigurierbar via `ALLOWED_ORIGINS` - Admin-Port 8001 über `ADMIN_HOST=127.0.0.1` auf lokalen Zugriff beschränkbar
## Konfiguration ## Konfiguration
`.env`-Datei im Projektverzeichnis anlegen (Vorlage: `.env.example`): `.env`-Datei im Projektverzeichnis anlegen:
```env ```env
ADMIN_PASSWORD=change-me ADMIN_PASSWORD=change-me
PROXY_HOST=0.0.0.0 PROXY_HOST=0.0.0.0
PROXY_PORT=8000 PROXY_PORT=8000
ADMIN_HOST=0.0.0.0
ADMIN_PORT=8001 ADMIN_PORT=8001
DATABASE_URL=sqlite:///./test.db DATABASE_URL=sqlite:///./test.db
OLLAMA_URL=http://localhost:11434 OLLAMA_URL=http://localhost:11434
DEFAULT_MODEL=llama3
APP_TZ=Europe/Berlin APP_TZ=Europe/Berlin
LOG_FILE=logs/usage.log
``` ```
| Variable | Standard | Beschreibung | | Variable | Standard | Beschreibung |
@@ -39,26 +42,16 @@ APP_TZ=Europe/Berlin
| `ADMIN_PASSWORD` | — | Passwort für die Admin-Oberfläche (**Pflicht**) | | `ADMIN_PASSWORD` | — | Passwort für die Admin-Oberfläche (**Pflicht**) |
| `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxys | | `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxys |
| `PROXY_PORT` | `8000` | Port des Proxys | | `PROXY_PORT` | `8000` | Port des Proxys |
| `ADMIN_HOST` | `0.0.0.0` | Bind-Adresse der Admin-API (z. B. `127.0.0.1` für lokalen Zugriff) |
| `ADMIN_PORT` | `8001` | Port der Admin-API | | `ADMIN_PORT` | `8001` | Port der Admin-API |
| `DATABASE_URL` | `sqlite:///./test.db` | DB-Verbindungsstring | | `DATABASE_URL` | `sqlite:///./test.db` | DB-Verbindungsstring (SQLite oder PostgreSQL) |
| `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz (auch in der UI änderbar) | | `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 | | `APP_TZ` | `Europe/Berlin` | Zeitzone für tägliche/monatliche Quota-Resets |
| `ALLOWED_ORIGINS` | `http://localhost:5173` | Kommagetrennte CORS-Origins | | `LOG_FILE` | `logs/usage.log` | Pfad der rotierenden Nutzungs-Logdatei |
| `ALLOWED_ORIGINS` | `http://localhost:5173` | CORS-Origins (nur für Entwicklung relevant) |
## Entwicklung (lokal) ## Entwicklung (lokal)
```bash
cp .env.example .env
# ADMIN_PASSWORD in .env setzen
./start.sh
```
Das Script prüft alle Ports auf Belegung, aktiviert automatisch eine vorhandene `.venv`, initialisiert die Datenbank und startet Proxy, Admin-API und Vite-Dev-Server.
Admin-Oberfläche: `http://localhost:5173`
### Voraussetzungen ### Voraussetzungen
- Python 3.12+ mit virtualenv - Python 3.12+ mit virtualenv
@@ -68,71 +61,117 @@ Admin-Oberfläche: `http://localhost:5173`
python -m venv .venv python -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r backend/requirements-dev.txt pip install -r backend/requirements-dev.txt
cd frontend && npm install 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) ## Produktion (Docker)
### Image bauen ### Docker Compose (empfohlen)
```bash ```bash
docker build -t llm-quota . docker compose up -d
``` ```
### Container starten Zieht das Image von DockerHub und lädt Variablen aus `.env`.
Das Setup verwendet `network_mode: host`: Der Container teilt den Netzwerkstack des Hosts, statt ein eigenes virtuelles Netzwerk zu bekommen. Das ist hier aus zwei Gründen die richtige Wahl:
1. **Ollama soll nicht von außen erreichbar sein.** Ollama läuft auf dem Host und ist an `127.0.0.1:11434` gebunden — nur lokal erreichbar. Mit einem eigenen Container-Netzwerk (Bridge-Mode) wäre `localhost` aus Sicht des Containers der Container selbst, nicht der Host. Die übliche Alternative (`host.docker.internal` + `extra_hosts`) ist auf Linux unzuverlässig.
2. **Kein doppeltes Port-Mapping nötig.** Mit `network_mode: host` sind Port 8000 und 8001 direkt auf dem Host verfügbar, ohne `ports:`-Einträge in der Compose-Datei.
### Image selbst bauen und pushen
```bash ```bash
docker run -d \ ./build_push.sh
-p 8000:8000 \
-p 127.0.0.1:8001:8001 \
-e ADMIN_PASSWORD=geheim \
-e OLLAMA_URL=http://host.docker.internal:11434 \
-e DATABASE_URL=sqlite:///./data/quota.db \
-v $(pwd)/data:/app/backend/data \
--name llm-quota \
llm-quota
``` ```
| Port | Bindung | Dienst | 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`.
|------|---------|--------|
| `8000` | `0.0.0.0` — öffentlich | Proxy (für LLM-Clients) |
| `8001` | `127.0.0.1` — nur lokal am Server | Admin-API + Admin-Oberfläche |
Docker unterscheidet beim Port-Mapping zwischen `0.0.0.0` (alle Interfaces, öffentlich erreichbar) und `127.0.0.1` (nur der Server selbst kann zugreifen). Mit `-p 127.0.0.1:8001:8001` ist Port 8001 am Server verfügbar, aber von außen nicht direkt ansprechbar. ### Port 8001 (Admin)
### Admin-Oberfläche per SSH-Tunnel erreichbar machen Alle Admin-Endpunkte erfordern das `ADMIN_PASSWORD` — der Token ist der primäre Schutz. Für zusätzliche Härtung lässt sich die Admin-API auf lokalen Zugriff beschränken:
Der SSH-Tunnel leitet einen lokalen Port auf den Server weiter und nutzt dabei, dass Port 8001 dort auf `127.0.0.1` erreichbar ist: ```env
ADMIN_HOST=127.0.0.1
```
Admin-Laptop:8001 ──SSH──► Server:127.0.0.1:8001 ──► Container:8001
``` ```
```bash Bei `network_mode: host` (Produktions-Standard) ist das die einzig wirksame Methode — Docker-Port-Mapping greift dort nicht.
ssh -L 8001:localhost:8001 user@server
### HTTPS via Reverse-Proxy (ungetestet)
Wer Proxy und Admin-Oberfläche per HTTPS bereitstellen will, kann einen weiteren Reverse-Proxy (z. B. Nginx oder Caddy) vorschalten. Bei `network_mode: host` lauschen beide Dienste direkt auf dem Host, Nginx/Caddy proxyen auf `localhost`.
**Caddy** (empfohlen — automatisches TLS via Let's Encrypt):
```
llm.example.com {
reverse_proxy localhost:8000 {
flush_interval -1
}
}
llm-admin.example.com {
reverse_proxy localhost:8001
}
``` ```
Danach ist die Admin-Oberfläche auf dem Laptop unter `http://localhost:8001` erreichbar — ohne dass Port 8001 öffentlich exponiert wird. **Nginx** (mit Certbot-Zertifikaten):
### Mit PostgreSQL ```nginx
server {
listen 443 ssl;
server_name llm.example.com;
```bash ssl_certificate /etc/letsencrypt/live/llm.example.com/fullchain.pem;
docker run -d \ ssl_certificate_key /etc/letsencrypt/live/llm.example.com/privkey.pem;
-p 8000:8000 \
-p 127.0.0.1:8001:8001 \ location / {
-e ADMIN_PASSWORD=geheim \ proxy_pass http://127.0.0.1:8000;
-e DATABASE_URL=postgresql://user:pass@db-host:5432/llm_quota \ proxy_set_header Host $host;
-e OLLAMA_URL=http://ollama:11434 \ proxy_set_header X-Real-IP $remote_addr;
llm-quota proxy_buffering off; # nötig für Streaming
proxy_cache off;
}
}
server {
listen 443 ssl;
server_name llm-admin.example.com;
ssl_certificate /etc/letsencrypt/live/llm-admin.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/llm-admin.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
``` ```
Clients konfigurieren dann `https://llm.example.com/v1` als Base URL.
## Proxy-Endpunkte (Port 8000) ## Proxy-Endpunkte (Port 8000)
Alle Endpunkte erfordern einen gültigen API-Key im `Authorization`-Header. Alle Endpunkte erfordern einen gültigen API-Key im `Authorization`-Header.
```bash ```bash
curl -X POST http://localhost:8000/api/chat \ curl -X POST http://localhost:8000/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","messages":[{"role":"user","content":"Hallo"}]}' -d '{"model":"llama3","messages":[{"role":"user","content":"Hallo"}]}'
@@ -140,12 +179,12 @@ curl -X POST http://localhost:8000/api/chat \
| Endpunkt | Methode | Beschreibung | | Endpunkt | Methode | Beschreibung |
|----------|---------|--------------| |----------|---------|--------------|
| `/api/generate` | POST | Ollama generate | | `/v1/chat/completions` | POST | Chat (OpenAI-Format, Streaming + Tool-Use) |
| `/api/chat` | POST | Ollama chat | | `/v1/models` | GET | Modelle (OpenAI-Format) |
| `/api/generate` | POST | Ollama generate (nativ) |
| `/api/chat` | POST | Ollama chat (nativ) |
| `/api/tags` | GET | Verfügbare Modelle | | `/api/tags` | GET | Verfügbare Modelle |
| `/api/versions` | GET | Ollama-Version | | `/api/versions` | GET | Ollama-Version |
| `/v1/models` | GET | Modelle (OpenAI-Format) |
| `/v1/chat/completions` | POST | Chat (OpenAI-Format) |
## Admin-API (Port 8001) ## Admin-API (Port 8001)
@@ -153,10 +192,12 @@ Alle Endpunkte erfordern `Authorization: Bearer <ADMIN_PASSWORD>`.
| Endpunkt | Methode | Beschreibung | | Endpunkt | Methode | Beschreibung |
|----------|---------|--------------| |----------|---------|--------------|
| `/api/api-keys` | GET | Alle API-Keys auflisten | | `/api/api-keys` | GET | Alle API-Keys mit Verbrauchsdaten |
| `/api/api-keys` | POST | Neuen API-Key erstellen | | `/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/api-keys/{id}/quota` | PATCH | Quota eines Keys aktualisieren | | `/api/api-keys/{id}` | DELETE | API-Key löschen |
| `/api/settings` | GET/PUT | Ollama-URL und Standard-Modell | | `/api/settings` | GET/PUT | Ollama-URL und Standard-Modell |
| `/api/ollama-models` | GET | Verfügbare Modelle von Ollama | | `/api/ollama-models` | GET | Verfügbare Modelle von Ollama |
| `/api/proxy-info` | GET | Lokaler Proxy-Endpunkt | | `/api/proxy-info` | GET | Lokaler Proxy-Endpunkt |
@@ -180,22 +221,27 @@ llm_quota/
│ ├── schemas.py # Pydantic-Schemas │ ├── schemas.py # Pydantic-Schemas
│ ├── crud.py # DB-Operationen, Token-Zählung, Quota-Logik │ ├── crud.py # DB-Operationen, Token-Zählung, Quota-Logik
│ ├── init_db.py # Tabellen anlegen & Settings seeden │ ├── init_db.py # Tabellen anlegen & Settings seeden
│ ├── setup_admin.py # Standard-API-Key erstellen
│ ├── requirements.txt # Produktiv-Dependencies │ ├── requirements.txt # Produktiv-Dependencies
│ ├── requirements-dev.txt # Test-Dependencies │ ├── requirements-dev.txt # Test-Dependencies
│ └── tests/ │ └── tests/
│ ├── conftest.py # Fixtures │ ├── conftest.py
│ ├── test_auth.py # Authentifizierungs-Tests │ ├── test_auth.py
│ └── test_quota.py # Quota-, Token- und Ablauf-Tests │ └── test_quota.py
├── frontend/ ├── frontend/
│ └── src/ │ └── src/
│ ├── main.jsx # React-Admin-UI │ ├── main.jsx # React-Admin-UI
│ └── styles.css │ └── styles.css
├── .idea/runConfigurations/
│ └── Dev.xml # PyCharm Run-Config
├── Dockerfile ├── Dockerfile
├── docker-compose.yml # Produktiv-Start mit DockerHub-Image
├── docker-entrypoint.sh ├── docker-entrypoint.sh
├── .dockerignore ├── .dockerignore
├── .env.example
├── start.sh # Entwicklungs-Startscript ├── 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 └── .gitignore
``` ```
+11 -6
View File
@@ -47,6 +47,8 @@ async def read_api_keys(
item.tokens_used_month = usage.tokens_used_month or 0 item.tokens_used_month = usage.tokens_used_month or 0
item.requests_today = usage.requests_today or 0 item.requests_today = usage.requests_today or 0
item.requests_month = usage.requests_month or 0 item.requests_month = usage.requests_month or 0
item.daily_reset_at = usage.daily_reset_at
item.monthly_reset_at = usage.monthly_reset_at
result.append(item) result.append(item)
return result return result
@@ -129,13 +131,16 @@ async def get_proxy_info(_ = Depends(require_admin_auth)):
host = os.getenv("PROXY_HOST", "0.0.0.0") host = os.getenv("PROXY_HOST", "0.0.0.0")
port = os.getenv("PROXY_PORT", "8000") port = os.getenv("PROXY_PORT", "8000")
display_host = "localhost" if host in ("0.0.0.0", "::") else host display_host = "localhost" if host in ("0.0.0.0", "::") else host
return {"endpoint": f"http://{display_host}:{port}"} return {
"endpoint": f"http://{display_host}:{port}",
"version": os.getenv("APP_VERSION", "dev"),
}
@app.get("/api/settings", response_model=schemas.Settings) @app.get("/api/settings", response_model=schemas.Settings)
async def read_settings(db: Session = Depends(get_db), _ = Depends(require_admin_auth)): async def read_settings(db: Session = Depends(get_db), _ = Depends(require_admin_auth)):
return schemas.Settings( return schemas.Settings(
ollama_url=crud.get_setting(db, "ollama_url", "http://localhost:11434"), ollama_url=crud.get_setting(db, "ollama_url", "http://localhost:11434"),
default_model=crud.get_setting(db, "default_model", "llama3"), force_model=crud.get_setting(db, "force_model") or None,
) )
@app.put("/api/settings", response_model=schemas.Settings) @app.put("/api/settings", response_model=schemas.Settings)
@@ -146,8 +151,8 @@ async def update_settings(
): ):
ollama_url = settings.ollama_url.rstrip('/').removesuffix('/v1') ollama_url = settings.ollama_url.rstrip('/').removesuffix('/v1')
crud.set_setting(db, "ollama_url", ollama_url) crud.set_setting(db, "ollama_url", ollama_url)
crud.set_setting(db, "default_model", settings.default_model) crud.set_setting(db, "force_model", settings.force_model or "")
return schemas.Settings(ollama_url=ollama_url, default_model=settings.default_model) return schemas.Settings(ollama_url=ollama_url, force_model=settings.force_model or None)
@app.get("/api/ollama-models") @app.get("/api/ollama-models")
async def get_ollama_models( async def get_ollama_models(
@@ -160,9 +165,9 @@ async def get_ollama_models(
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{ollama_url}/api/tags") response = await client.get(f"{ollama_url}/api/tags")
models = [m["name"] for m in response.json().get("models", [])] models = [m["name"] for m in response.json().get("models", [])]
return {"models": models, "reachable": True}
except Exception: except Exception:
models = [] return {"models": [], "reachable": False}
return {"models": models}
# Statisches Frontend ausliefern (nur im Produktivbetrieb, wenn dist/ existiert) # Statisches Frontend ausliefern (nur im Produktivbetrieb, wenn dist/ existiert)
_dist = Path(__file__).parent.parent / "frontend" / "dist" _dist = Path(__file__).parent.parent / "frontend" / "dist"
-2
View File
@@ -13,8 +13,6 @@ def init_db():
db = SessionLocal() db = SessionLocal()
if not get_setting(db, "ollama_url"): if not get_setting(db, "ollama_url"):
set_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) 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() db.close()
print("Database initialized.") print("Database initialized.")
+136 -16
View File
@@ -1,5 +1,7 @@
import json
import logging import logging
import os import os
import time
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
@@ -10,20 +12,40 @@ from database import get_db
import crud import crud
import httpx import httpx
_log_dir = Path(os.getenv("LOG_FILE", "logs/usage.log")).parent
_log_dir.mkdir(parents=True, exist_ok=True)
_fmt = logging.Formatter("%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
# Rotating usage log (8 KB per file, 3 backups) # Rotating usage log (8 KB per file, 3 backups)
_log_path = Path(os.getenv("LOG_FILE", "logs/usage.log")) _usage_handler = RotatingFileHandler(str(_log_dir / "usage.log"), maxBytes=8192, backupCount=3, encoding="utf-8")
_log_path.parent.mkdir(parents=True, exist_ok=True) _usage_handler.setFormatter(_fmt)
_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 = logging.getLogger("proxy.usage")
usage_log.setLevel(logging.INFO) usage_log.setLevel(logging.INFO)
usage_log.addHandler(_handler) usage_log.addHandler(_usage_handler)
usage_log.propagate = False usage_log.propagate = False
# Rotating error log (64 KB per file, 5 backups)
_error_handler = RotatingFileHandler(str(_log_dir / "error.log"), maxBytes=65536, backupCount=5, encoding="utf-8")
_error_handler.setFormatter(_fmt)
error_log = logging.getLogger("proxy.error")
error_log.setLevel(logging.ERROR)
error_log.addHandler(_error_handler)
error_log.propagate = False
def _content_to_str(content) -> str:
"""Normalize OpenAI content: string or array of content parts → plain string."""
if isinstance(content, list):
return " ".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in content
)
return content or ""
def _last_user_msg(messages: list, max_len: int = 120) -> str: def _last_user_msg(messages: list, max_len: int = 120) -> str:
for msg in reversed(messages): for msg in reversed(messages):
if msg.get("role") == "user": if msg.get("role") == "user":
text = (msg.get("content") or "").replace("\n", " ").strip() text = _content_to_str(msg.get("content")).replace("\n", " ").strip()
return text[:max_len] + ("…" if len(text) > max_len else "") return text[:max_len] + ("…" if len(text) > max_len else "")
return "" return ""
@@ -43,6 +65,23 @@ async def require_api_key(request: Request, db: Session = Depends(get_db)):
app = FastAPI(title="Ollama Proxy", dependencies=[Depends(require_api_key)]) app = FastAPI(title="Ollama Proxy", dependencies=[Depends(require_api_key)])
@app.on_event("startup")
def apply_env_settings():
"""Write env-configured values into DB so they take effect until next restart."""
db = next(get_db())
try:
if url := os.getenv("OLLAMA_URL"):
crud.set_setting(db, "ollama_url", url)
db.commit()
finally:
db.close()
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
error_log.error("Unhandled exception | %s %s | %s: %s",
request.method, request.url.path, type(exc).__name__, exc, exc_info=exc)
return JSONResponse(status_code=500, content={"error": {"message": "Internal server error", "type": "server_error"}})
async def proxy_request(url: str, method: str = "GET", json_data: dict = None): async def proxy_request(url: str, method: str = "GET", json_data: dict = None):
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.request(method=method, url=url, json=json_data) response = await client.request(method=method, url=url, json=json_data)
@@ -52,6 +91,11 @@ async def proxy_request(url: str, method: str = "GET", json_data: dict = None):
async def generate(request: Request, db: Session = Depends(get_db)): async def generate(request: Request, db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
body = await request.json() body = await request.json()
force_model = crud.get_setting(db, "force_model") or None
if force_model:
body = {**body, "model": force_model}
if not body.get("model"):
raise HTTPException(status_code=422, detail="Field 'model' is required")
prompt_tokens = crud.count_tokens(body.get("prompt", "")) prompt_tokens = crud.count_tokens(body.get("prompt", ""))
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, request.state.api_key_id, tokens=prompt_tokens, requests=1):
@@ -60,23 +104,50 @@ async def generate(request: Request, db: Session = Depends(get_db)):
prompt_preview = (body.get("prompt", "").replace("\n", " ").strip())[:120] prompt_preview = (body.get("prompt", "").replace("\n", " ").strip())[:120]
usage_log.info('%s | /api/generate | %s | ~%d tokens | "%s"', usage_log.info('%s | /api/generate | %s | ~%d tokens | "%s"',
request.state.api_key_name, body.get("model", "?"), prompt_tokens, prompt_preview) request.state.api_key_name, body.get("model", "?"), prompt_tokens, prompt_preview)
start = time.monotonic()
try:
response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body) response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code) resp_json = response.json()
usage_log.info('%s | /api/generate | %s | actual ↑%d ↓%d tokens | %.1fs',
request.state.api_key_name, body.get("model", "?"),
resp_json.get("prompt_eval_count", 0), resp_json.get("eval_count", 0),
time.monotonic() - start)
return JSONResponse(content=resp_json, status_code=response.status_code)
except Exception as exc:
error_log.error("Proxy error | %s | /api/generate | %s | %s: %s",
request.state.api_key_name, body.get("model", "?"), type(exc).__name__, exc, exc_info=exc)
raise
@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)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
body = await request.json() body = await request.json()
force_model = crud.get_setting(db, "force_model") or None
if force_model:
body = {**body, "model": force_model}
if not body.get("model"):
raise HTTPException(status_code=422, detail="Field 'model' is required")
messages = body.get("messages", []) messages = body.get("messages", [])
prompt_tokens = sum(crud.count_tokens(msg.get("content") or "") for msg in messages) prompt_tokens = sum(crud.count_tokens(_content_to_str(msg.get("content"))) for msg in 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, 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")
usage_log.info('%s | /api/chat | %s | ~%d tokens | "%s"', usage_log.info('%s | /api/chat | %s | ~%d tokens | "%s"',
request.state.api_key_name, body.get("model", "?"), prompt_tokens, _last_user_msg(messages)) request.state.api_key_name, body.get("model", "?"), prompt_tokens, _last_user_msg(messages))
start = time.monotonic()
try:
response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body) response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code) resp_json = response.json()
usage_log.info('%s | /api/chat | %s | actual ↑%d ↓%d tokens | %.1fs',
request.state.api_key_name, body.get("model", "?"),
resp_json.get("prompt_eval_count", 0), resp_json.get("eval_count", 0),
time.monotonic() - start)
return JSONResponse(content=resp_json, status_code=response.status_code)
except Exception as exc:
error_log.error("Proxy error | %s | /api/chat | %s | %s: %s",
request.state.api_key_name, body.get("model", "?"), type(exc).__name__, exc, exc_info=exc)
raise
@app.get("/api/tags") @app.get("/api/tags")
async def list_models(db: Session = Depends(get_db)): async def list_models(db: Session = Depends(get_db)):
@@ -84,6 +155,16 @@ async def list_models(db: Session = Depends(get_db)):
response = await proxy_request(f"{ollama_url}/api/tags", method="GET") response = await proxy_request(f"{ollama_url}/api/tags", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code) return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/version")
async def version():
return {"version": os.getenv("APP_VERSION", "dev")}
@app.get("/api/ps")
async def running_models(db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
response = await proxy_request(f"{ollama_url}/api/ps", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/api/versions") @app.get("/api/versions")
async def versions(db: Session = Depends(get_db)): async def versions(db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
@@ -99,35 +180,74 @@ async def list_openai_models(db: Session = Depends(get_db)):
@app.post("/v1/chat/completions") @app.post("/v1/chat/completions")
async def openai_chat_completions(request: Request, db: Session = Depends(get_db)): async def openai_chat_completions(request: Request, db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) 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()
force_model = crud.get_setting(db, "force_model") or None
if force_model:
body = {**body, "model": force_model}
if not body.get("model"):
raise HTTPException(status_code=422, detail="Field 'model' is required")
messages = body.get("messages", []) messages = body.get("messages", [])
prompt_tokens = sum(crud.count_tokens(msg.get("content") or "") for msg in messages) prompt_tokens = sum(crud.count_tokens(_content_to_str(msg.get("content"))) for msg in 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, 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")
if "model" not in body:
body = {**body, "model": default_model}
model_name = body["model"] model_name = body["model"]
usage_log.info('%s | /v1/chat/completions | %s | ~%d tokens | "%s"', usage_log.info('%s | /v1/chat/completions | %s | ~%d tokens | "%s"',
request.state.api_key_name, model_name, prompt_tokens, _last_user_msg(messages)) request.state.api_key_name, model_name, prompt_tokens, _last_user_msg(messages))
target = f"{ollama_url}/v1/chat/completions" target = f"{ollama_url}/v1/chat/completions"
if body.get("stream"): if body.get("stream"):
existing_opts = body.get("stream_options") or {}
stream_body = {**body, "stream_options": {**existing_opts, "include_usage": True}}
start = time.monotonic()
usage_tokens = {"prompt": 0, "completion": 0}
async def generate(): async def generate():
try:
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream("POST", target, json=body) as resp: async with client.stream("POST", target, json=stream_body) as resp:
async for chunk in resp.aiter_bytes(): async for chunk in resp.aiter_bytes():
try:
for line in chunk.decode("utf-8", errors="ignore").splitlines():
if line.startswith("data: ") and "[DONE]" not in line:
data = json.loads(line[6:])
if u := data.get("usage"):
usage_tokens["prompt"] = u.get("prompt_tokens", 0)
usage_tokens["completion"] = u.get("completion_tokens", 0)
except Exception:
pass
yield chunk yield chunk
except Exception as exc:
error_log.error("Stream error | %s | /v1/chat/completions | %s | %s: %s",
request.state.api_key_name, model_name, type(exc).__name__, exc, exc_info=exc)
raise
finally:
usage_log.info('%s | /v1/chat/completions | %s | actual ↑%d ↓%d tokens | %.1fs',
request.state.api_key_name, model_name,
usage_tokens["prompt"], usage_tokens["completion"],
time.monotonic() - start)
return StreamingResponse( return StreamingResponse(
generate(), generate(),
media_type="text/event-stream", media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
) )
start = time.monotonic()
try:
response = await proxy_request(target, method="POST", json_data=body) response = await proxy_request(target, method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code) resp_json = response.json()
usage = resp_json.get("usage", {})
usage_log.info('%s | /v1/chat/completions | %s | actual ↑%d ↓%d tokens | %.1fs',
request.state.api_key_name, model_name,
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
time.monotonic() - start)
return JSONResponse(content=resp_json, status_code=response.status_code)
except Exception as exc:
error_log.error("Proxy error | %s | /v1/chat/completions | %s | %s: %s",
request.state.api_key_name, model_name, type(exc).__name__, exc, exc_info=exc)
raise
+3 -1
View File
@@ -40,7 +40,7 @@ class QuotaUpdate(BaseModel):
class Settings(BaseModel): class Settings(BaseModel):
ollama_url: str ollama_url: str
default_model: str force_model: Optional[str] = None
class UsageStats(BaseModel): class UsageStats(BaseModel):
tokens_used_today: int = 0 tokens_used_today: int = 0
@@ -58,6 +58,8 @@ class APIKeyWithUsage(APIKey):
tokens_used_month: int = 0 tokens_used_month: int = 0
requests_today: int = 0 requests_today: int = 0
requests_month: int = 0 requests_month: int = 0
daily_reset_at: Optional[datetime] = None
monthly_reset_at: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True
+16 -8
View File
@@ -5,17 +5,24 @@ cd "$(dirname "$0")"
IMAGE=mediaeng/llmproxy IMAGE=mediaeng/llmproxy
PLATFORM=linux/arm64 PLATFORM=linux/arm64
CURRENT=$(git describe --tags --always) LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
if [ -z "$CURRENT" ]; then HEAD_TAG=$(git tag --points-at HEAD | head -1)
echo "Fehler: git describe liefert kein Ergebnis"
if [ -n "$HEAD_TAG" ]; then
echo "HEAD bereits getaggt: $HEAD_TAG"
read -rp "Neuer Tag [${HEAD_TAG}]: " INPUT
VERSION="${INPUT:-$HEAD_TAG}"
else
echo "Letzter Tag: ${LAST_TAG:-kein Tag}"
read -rp "Neuer Tag: " INPUT
if [ -z "$INPUT" ]; then
echo "Kein Tag angegeben, breche ab."
exit 1 exit 1
fi fi
VERSION="$INPUT"
fi
echo "Aktueller Tag: $CURRENT" if [ "$VERSION" != "$HEAD_TAG" ]; then
read -rp "Neuer Tag [${CURRENT}]: " INPUT
VERSION="${INPUT:-$CURRENT}"
if [ "$VERSION" != "$CURRENT" ]; then
git tag "$VERSION" git tag "$VERSION"
git push origin "$VERSION" git push origin "$VERSION"
echo "Tag '$VERSION' gesetzt und gepusht." echo "Tag '$VERSION' gesetzt und gepusht."
@@ -30,6 +37,7 @@ echo ""
docker buildx build \ docker buildx build \
--platform "$PLATFORM" \ --platform "$PLATFORM" \
--push \ --push \
--build-arg APP_VERSION="$VERSION" \
-t "$IMAGE:$VERSION" \ -t "$IMAGE:$VERSION" \
-t "$IMAGE:latest" \ -t "$IMAGE:latest" \
. .
+3 -2
View File
@@ -1,10 +1,11 @@
services: services:
llmproxy: llmproxy:
image: mediaeng/llmproxy:latest image: mediaeng/llmproxy:latest
container_name: llmproxy
restart: unless-stopped restart: unless-stopped
network_mode: host
env_file: .env env_file: .env
ports:
- "${PROXY_PORT:-8000}:${PROXY_PORT:-8000}"
volumes: volumes:
- ./backend/test.db:/app/backend/test.db - ./backend/test.db:/app/backend/test.db
- ./backend/logs:/app/backend/logs - ./backend/logs:/app/backend/logs
+1 -1
View File
@@ -10,7 +10,7 @@ uvicorn main:app \
PROXY_PID=$! PROXY_PID=$!
uvicorn admin:app \ uvicorn admin:app \
--host "0.0.0.0" \ --host "${ADMIN_HOST:-0.0.0.0}" \
--port "${ADMIN_PORT:-8001}" & --port "${ADMIN_PORT:-8001}" &
ADMIN_PID=$! ADMIN_PID=$!
+44 -19
View File
@@ -11,17 +11,28 @@ function authHeaders(token) {
const fmtK = (n) => { const k = n / 1000; return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`; }; const fmtK = (n) => { const k = n / 1000; return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`; };
function QuotaBar({ used, limit, isToken = false }) { function QuotaBar({ used, limit, isToken = false, since = null }) {
if (limit == null) return <span className="quota-unlimited">∞</span>; 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 pct = Math.min(100, (used / limit) * 100);
const color = pct >= 90 ? '#e74c3c' : pct >= 70 ? '#e67e22' : '#27ae60'; const color = pct >= 90 ? '#e74c3c' : pct >= 70 ? '#e67e22' : '#27ae60';
const fmt = isToken ? fmtK : (n) => n.toLocaleString('de-DE');
return ( return (
<div className="quota-cell"> <div className="quota-cell">
<span className="quota-label">{fmt(used)} / {fmt(limit)}</span> <span className="quota-label">{fmt(used)} / {fmt(limit)}</span>
<div className="progress-bar"> <div className="progress-bar">
<div className="progress-fill" style={{ width: `${pct}%`, backgroundColor: color }} /> <div className="progress-fill" style={{ width: `${pct}%`, backgroundColor: color }} />
</div> </div>
{sinceLabel && <span className="quota-since">seit {sinceLabel}</span>}
</div> </div>
); );
} }
@@ -69,7 +80,9 @@ function SettingsSection({ password }) {
const [settings, setSettings] = useState(null); const [settings, setSettings] = useState(null);
const [availableModels, setAvailableModels] = useState([]); const [availableModels, setAvailableModels] = useState([]);
const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoading, setModelsLoading] = useState(false);
const [ollamaReachable, setOllamaReachable] = useState(true);
const [proxyEndpoint, setProxyEndpoint] = useState(null); const [proxyEndpoint, setProxyEndpoint] = useState(null);
const [appVersion, setAppVersion] = useState(null);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -80,12 +93,14 @@ function SettingsSection({ password }) {
headers: authHeaders(password), headers: authHeaders(password),
params: url ? { url } : {}, params: url ? { url } : {},
}); });
const models = res.data.models; const { models, reachable } = res.data;
setOllamaReachable(reachable);
setAvailableModels(models); setAvailableModels(models);
if (models.length > 0 && !models.includes(currentModel)) { if (models.length > 0 && currentModel && !models.includes(currentModel)) {
setSettings(s => ({ ...s, default_model: models[0] })); setSettings(s => ({ ...s, force_model: models[0] }));
} }
} catch { } catch {
setOllamaReachable(false);
setAvailableModels([]); setAvailableModels([]);
} finally { } finally {
setModelsLoading(false); setModelsLoading(false);
@@ -101,7 +116,8 @@ function SettingsSection({ password }) {
const s = settingsRes.data; const s = settingsRes.data;
setSettings(s); setSettings(s);
setProxyEndpoint(proxyRes.data.endpoint); setProxyEndpoint(proxyRes.data.endpoint);
fetchModels(s.ollama_url, s.default_model); setAppVersion(proxyRes.data.version);
fetchModels(s.ollama_url, s.force_model);
}).catch(() => setError('Einstellungen konnten nicht geladen werden.')); }).catch(() => setError('Einstellungen konnten nicht geladen werden.'));
}, []); }, []);
@@ -131,35 +147,44 @@ function SettingsSection({ password }) {
<small> (Änderung erfordert Neustart)</small> <small> (Änderung erfordert Neustart)</small>
</span> </span>
</div> </div>
<div className="settings-row">
<label>Version</label>
<span className="settings-value">{appVersion ?? '…'}</span>
</div>
<div className="settings-row"> <div className="settings-row">
<label>Ollama-Endpunkt</label> <label>Ollama-Endpunkt</label>
<div className="settings-input-wrap">
<input <input
type="url" type="url"
value={settings.ollama_url} value={settings.ollama_url}
onChange={(e) => setSettings({ ...settings, ollama_url: e.target.value })} onChange={(e) => setSettings({ ...settings, ollama_url: e.target.value })}
onBlur={(e) => fetchModels(e.target.value, settings.default_model)} onBlur={(e) => fetchModels(e.target.value, settings.force_model)}
placeholder="http://localhost:11434" placeholder="http://localhost:11434"
required required
/> />
{!ollamaReachable && !modelsLoading && (
<div className="warning">⚠ Ollama nicht erreichbar unter {settings.ollama_url}</div>
)}
</div>
</div> </div>
<div className="settings-row"> <div className="settings-row">
<label>Standard-Modell</label> <label>Aktives Modell (Lock)</label>
{modelsLoading ? ( {modelsLoading ? (
<span className="settings-value">Lade Modelle…</span> <span className="settings-value">Lade Modelle…</span>
) : availableModels.length > 0 ? ( ) : availableModels.length > 0 ? (
<select <select
value={settings.default_model} value={settings.force_model || ""}
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })} onChange={(e) => setSettings({ ...settings, force_model: e.target.value || null })}
> >
<option value="">— kein Lock —</option>
{availableModels.map(m => <option key={m} value={m}>{m}</option>)} {availableModels.map(m => <option key={m} value={m}>{m}</option>)}
</select> </select>
) : ( ) : (
<input <input
type="text" type="text"
value={settings.default_model} value={settings.force_model || ""}
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })} onChange={(e) => setSettings({ ...settings, force_model: e.target.value || null })}
placeholder="llama3" placeholder="leer = kein Lock"
required
/> />
)} )}
</div> </div>
@@ -400,10 +425,10 @@ function App() {
<td>{key.name}</td> <td>{key.name}</td>
<td>{displayKey(key.key_prefix)}</td> <td>{displayKey(key.key_prefix)}</td>
<td>{key.expires_at ? new Date(key.expires_at).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '∞'}</td> <td>{key.expires_at ? new Date(key.expires_at).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '∞'}</td>
<td><QuotaBar used={key.tokens_used_today} limit={key.daily_tokens} isToken /></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 /></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} /></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} /></td> <td><QuotaBar used={key.requests_month} limit={key.monthly_requests} since={key.monthly_reset_at} /></td>
<td className="action-cell"> <td className="action-cell">
<button className="btn-icon btn-icon-edit" data-tooltip="Bearbeiten" onClick={() => handleEdit(key)}>✏</button> <button className="btn-icon btn-icon-edit" data-tooltip="Bearbeiten" onClick={() => handleEdit(key)}>✏</button>
{key.is_active ? ( {key.is_active ? (
+32
View File
@@ -194,6 +194,31 @@ tr:hover {
font-size: 14px; font-size: 14px;
} }
.settings-input-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.settings-input-wrap input {
width: 100%;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.warning {
color: #b8520a;
background: #fff3e0;
border: 1px solid #e67e22;
border-radius: 4px;
padding: 6px 10px;
font-size: 13px;
}
.settings-form button { .settings-form button {
align-self: flex-start; align-self: flex-start;
padding: 8px 20px; padding: 8px 20px;
@@ -246,6 +271,13 @@ tr:hover {
font-size: 14px; font-size: 14px;
} }
.quota-since {
display: block;
font-size: 10px;
color: #aaa;
margin-top: 2px;
}
.progress-bar { .progress-bar {
height: 4px; height: 4px;
background: #e2e8f0; background: #e2e8f0;
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
clearScreen: false, clearScreen: false,
server: { server: {
open: true,
proxy: { proxy: {
'/api/api-keys': 'http://localhost:8001', '/api/api-keys': 'http://localhost:8001',
'/api/settings': 'http://localhost:8001', '/api/settings': 'http://localhost:8001',
+3 -2
View File
@@ -40,13 +40,14 @@ def main():
proxy_host = os.environ.get('PROXY_HOST', '0.0.0.0') proxy_host = os.environ.get('PROXY_HOST', '0.0.0.0')
proxy_port = os.environ.get('PROXY_PORT', '8000') proxy_port = os.environ.get('PROXY_PORT', '8000')
admin_host = os.environ.get('ADMIN_HOST', '127.0.0.1')
admin_port = os.environ.get('ADMIN_PORT', '8001') admin_port = os.environ.get('ADMIN_PORT', '8001')
print('Initialisiere Datenbank...') print('Initialisiere Datenbank...')
subprocess.run([str(python), 'init_db.py'], cwd=backend, check=True) subprocess.run([str(python), 'init_db.py'], cwd=backend, check=True)
print(f'Starte Proxy → http://{proxy_host}:{proxy_port}') print(f'Starte Proxy → http://{proxy_host}:{proxy_port}')
print(f'Starte Admin-API → http://127.0.0.1:{admin_port}') print(f'Starte Admin-API → http://{admin_host}:{admin_port}')
print('Starte Frontend → http://localhost:5173') print('Starte Frontend → http://localhost:5173')
env = {**os.environ, 'PYTHONUNBUFFERED': '1'} env = {**os.environ, 'PYTHONUNBUFFERED': '1'}
@@ -59,7 +60,7 @@ def main():
), 'Proxy ', '34'), # blau ), 'Proxy ', '34'), # blau
(subprocess.Popen( (subprocess.Popen(
[str(python), '-m', 'uvicorn', 'admin:app', '--reload', [str(python), '-m', 'uvicorn', 'admin:app', '--reload',
'--host', '127.0.0.1', '--port', admin_port], '--host', admin_host, '--port', admin_port],
cwd=backend, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, cwd=backend, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env,
), 'Admin ', '33'), # gelb ), 'Admin ', '33'), # gelb
(subprocess.Popen( (subprocess.Popen(
+4 -4
View File
@@ -21,6 +21,7 @@ fi
PROXY_HOST=${PROXY_HOST:-0.0.0.0} PROXY_HOST=${PROXY_HOST:-0.0.0.0}
PROXY_PORT=${PROXY_PORT:-8000} PROXY_PORT=${PROXY_PORT:-8000}
ADMIN_HOST=${ADMIN_HOST:-127.0.0.1}
ADMIN_PORT=${ADMIN_PORT:-8001} ADMIN_PORT=${ADMIN_PORT:-8001}
FRONTEND_PORT=5173 FRONTEND_PORT=5173
@@ -60,9 +61,8 @@ cd backend
python3 -m uvicorn main:app --reload --host "$PROXY_HOST" --port "$PROXY_PORT" & python3 -m uvicorn main:app --reload --host "$PROXY_HOST" --port "$PROXY_PORT" &
PIDS+=($!) PIDS+=($!)
# Admin-API immer nur lokal erreichbar (Host nicht konfigurierbar) echo "Starte Admin-API auf ${ADMIN_HOST}:${ADMIN_PORT}..."
echo "Starte Admin-API auf 127.0.0.1:${ADMIN_PORT}..." python3 -m uvicorn admin:app --reload --host "$ADMIN_HOST" --port "$ADMIN_PORT" &
python3 -m uvicorn admin:app --reload --host 127.0.0.1 --port "$ADMIN_PORT" &
PIDS+=($!) PIDS+=($!)
cd .. cd ..
@@ -75,7 +75,7 @@ PIDS+=($!)
cd .. cd ..
echo "Backend läuft (Port $PROXY_PORT)" echo "Backend läuft (Port $PROXY_PORT)"
echo "Admin-API läuft (Port $ADMIN_PORT, nur lokal)" echo "Admin-API läuft (${ADMIN_HOST}:${ADMIN_PORT})"
echo "Admin-Oberfläche: http://localhost:$FRONTEND_PORT" echo "Admin-Oberfläche: http://localhost:$FRONTEND_PORT"
wait wait