Add reproducible Docker and WireGuard host bootstrap
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.gitignore
|
||||
config/install.env
|
||||
*.local.*
|
||||
*.log
|
||||
__pycache__
|
||||
*.pyc
|
||||
docs
|
||||
dev
|
||||
deploy
|
||||
benchmarks
|
||||
artifacts
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated as /etc/mike-ai/stack.env by install.sh. Never commit real values.
|
||||
AI_BIND_ADDRESS=10.77.0.2
|
||||
MODEL_DIR=/srv/mike-ai/models
|
||||
ROUTER_API_KEY=GENERATED_BY_INSTALLER
|
||||
CONTROLLER_TOKEN=GENERATED_BY_INSTALLER
|
||||
WEBUI_SECRET_KEY=GENERATED_BY_INSTALLER
|
||||
OPENWEBUI_IMAGE=ghcr.io/open-webui/open-webui:v0.9.5
|
||||
AI_DNS=192.168.1.1
|
||||
|
||||
FAST_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
MEDIUM_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
LONG_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
EXPERIMENTAL_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
VISION_PROJECTOR_FILE=qwen/mmproj-BF16.gguf
|
||||
MTP_MODEL_FILE=qwen/mtp-Qwen3.8-27B-Q4_0.gguf
|
||||
|
||||
FAST_CONTEXT=76800
|
||||
MEDIUM_CONTEXT=94208
|
||||
LONG_CONTEXT=131072
|
||||
EXPERIMENTAL_CONTEXT=76800
|
||||
FAST_GPU_DEVICES=0
|
||||
MEDIUM_GPU_DEVICES=0
|
||||
LONG_GPU_DEVICES=0
|
||||
EXPERIMENTAL_GPU_DEVICES=0
|
||||
LLAMA_THREADS=6
|
||||
LLAMA_THREADS_BATCH=6
|
||||
@@ -28,3 +28,5 @@ logs/
|
||||
*.bin
|
||||
.venv/
|
||||
venv/
|
||||
config/install.env
|
||||
platform/web-search/searxng-settings.yml
|
||||
|
||||
@@ -1,592 +1,83 @@
|
||||
# Lokale KI-Plattform
|
||||
|
||||
Dieses private Repository dokumentiert und installiert die reproduzierbare
|
||||
KI-Umgebung rund um einen lokalen `llama.cpp`-Host. Der **AI Profile Router**
|
||||
bleibt der zentrale Bestandteil, ist aber nicht mehr die einzige Komponente.
|
||||
Reproduzierbarer Docker-Stack für einen privaten Qwen-/llama.cpp-Host mit
|
||||
Open WebUI, Profilumschaltung, integrierter Vision, lokaler Websuche und
|
||||
WireGuard-Isolation.
|
||||
|
||||
## Plattform auf einen Blick
|
||||
## Zielbild
|
||||
|
||||
```text
|
||||
Clients (Open WebUI, Hermes, Apps)
|
||||
|
|
||||
v
|
||||
AI Profile Router :8081
|
||||
|-- qwen-fast (76.8K, maximale Geschwindigkeit)
|
||||
|-- qwen-medium (92K, reines IQ4_XS)
|
||||
|-- qwen-long (128K, CPU-Offload)
|
||||
|-- integrierte Qwen-Vision (kein Hotswap)
|
||||
|-- lokale Bildgenerierung
|
||||
|-- Whisper STT
|
||||
`-- XTTS TTS
|
||||
|
|
||||
v
|
||||
llama.cpp :8080 + profilabhängige MCP-Server
|
||||
- Debian 13 als schlanker GPU-Host
|
||||
- llama.cpp selbst gebaut und auf einen geprüften Commit festgelegt
|
||||
- vier getrennte Profilcontainer, davon immer exakt einer aktiv
|
||||
- `/fast`, `/medium`, `/long` und `/experimental` über den Profile Router
|
||||
- Open WebUI als einzige normale Oberfläche
|
||||
- SearXNG/Web-MCP ohne externen API-Schlüssel
|
||||
- KI-Dienste ausschließlich über die WireGuard-Adresse erreichbar
|
||||
- KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed
|
||||
- keine Secrets, Chats, Logs oder Modelldateien im Repository
|
||||
|
||||
## Schnellstart
|
||||
|
||||
Auf einem frisch installierten Debian 12/13 amd64:
|
||||
|
||||
```bash
|
||||
cp config/install.env.example config/install.env
|
||||
chmod 600 config/install.env
|
||||
editor config/install.env
|
||||
sudo ./install.sh --config config/install.env
|
||||
```
|
||||
|
||||
### Enthalten
|
||||
Installiert werden Docker CE, NVIDIA Container Toolkit, WireGuard, der
|
||||
gepinnt gebaute llama.cpp-Server, die Modelle und der komplette Compose-Stack.
|
||||
Bei einer erstmaligen NVIDIA-Treiberinstallation fordert das Skript einen
|
||||
Neustart an; danach wird derselbe Befehl erneut ausgeführt.
|
||||
|
||||
- produktiver Router samt Tests und Deployment
|
||||
- fest definierte Fast-/Medium-/Long-Profile
|
||||
- reproduzierbarer `llama.cpp`-Build über einen festgelegten Commit
|
||||
- systemd-Vorlagen und Profilumschaltung
|
||||
- Modellmanifest ohne Modelldateien
|
||||
- sichere MCP-Beispielkonfiguration ohne Zugangsdaten
|
||||
- Installations-, Betriebs-, Sicherheits- und Migrationsanleitung
|
||||
- Prüfskript für einen frisch installierten Host
|
||||
## Dienste
|
||||
|
||||
### Bewusst nicht enthalten
|
||||
| Dienst | Erreichbarkeit | Zweck |
|
||||
|---|---|---|
|
||||
| Open WebUI | `<WG-IP>:8080` | Chat und Administration |
|
||||
| Profile Router | `<WG-IP>:8081` | OpenAI-kompatible API, Profilwahl |
|
||||
| llama.cpp | nur Docker-intern | Inferenz, Vision, MCP |
|
||||
| Profile Controller | nur Docker-intern | eng begrenzter Containerwechsel |
|
||||
| SearXNG | nur Docker-intern | Websuche |
|
||||
|
||||
- GGUF-, Whisper-, FLUX- oder XTTS-Modelldateien
|
||||
- API-Schlüssel, Tokens, SSH-Schlüssel oder Zertifikate
|
||||
- Chats, Prompts, Logs, Bilder oder Audiodateien
|
||||
- alte Benchmarks, experimentelle Builds und ausgemusterte RX-Dienste
|
||||
- hostgebundene Backups und Cache-Verzeichnisse
|
||||
Bildgenerierung, TTS/STT sowie Home-Assistant-, ARR- und Unraid-MCPs werden
|
||||
bewusst nicht automatisch aktiviert. Sie erhalten später eigene Container und
|
||||
kleinstmögliche Rechte. Die Bildanalyse ist bereits Bestandteil des
|
||||
multimodalen Qwen-Modells.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- [Architektur](docs/ARCHITECTURE.md)
|
||||
- [Komponentenverzeichnis](docs/COMPONENTS.md)
|
||||
- [Aktueller produktiver Referenzstand](docs/CURRENT_REFERENCE.md)
|
||||
- [Noch benötigte Wiederherstellungsartefakte](docs/RECOVERY_REQUIREMENTS.md)
|
||||
- [Disaster Recovery und Abnahme](docs/DISASTER_RECOVERY.md)
|
||||
- [Saubere Installation](docs/INSTALLATION.md)
|
||||
- [Roadmap für den neuen Host](docs/NEW_HOST_ROADMAP.md)
|
||||
- [Zielarchitektur und Sicherheitsgrenzen](docs/ARCHITECTURE.md)
|
||||
- [Installation und Abnahme](docs/INSTALLATION.md)
|
||||
- [WireGuard-Heimseite](docs/WIREGUARD_HOME_PEER.md)
|
||||
- [Betrieb und Profilwechsel](docs/OPERATIONS.md)
|
||||
- [Sicherheitsmodell](docs/SECURITY.md)
|
||||
- [Router V2: Migration und Kompatibilität](docs/ROUTER_V2_MIGRATION.md)
|
||||
- [Migration vom bestehenden Host](docs/MIGRATION.md)
|
||||
- [MCP-Aufteilung](platform/mcp/README.md)
|
||||
- [llama.cpp-Build und Profile](platform/llama/README.md)
|
||||
- [Disaster Recovery](docs/DISASTER_RECOVERY.md)
|
||||
- [Komponenten](docs/COMPONENTS.md)
|
||||
|
||||
## AI Profile Router
|
||||
## Wichtige Dateien
|
||||
|
||||
Kleiner OpenAI-kompatibler Proxy (Python, nur Standardbibliothek) vor einem
|
||||
lokalen llama.cpp-Server. Er leitet normale OpenAI-Requests transparent
|
||||
weiter (Streaming, Tool Calls, JSON und Bilder), schaltet zwischen drei festen
|
||||
multimodalen llama.cpp-Profilen um, orchestriert lokale Bildgenerierung mit
|
||||
FLUX.2 [klein] 4B Base (GPU-Hotswap: Qwen stoppen → FLUX laden → Bild
|
||||
→ FLUX entladen → Qwen wiederherstellen) und stellt lokale deutsche
|
||||
Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel).
|
||||
|
||||
## Zielsystem
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Host | frei wählbarer Linux-KI-Host |
|
||||
| SSH | administrativer Zugang nur für Installation und Wartung |
|
||||
| llama.cpp | `http://127.0.0.1:8080` (Service `mike-ai-llama-ui.service`) |
|
||||
| Profil-Skript | `/usr/local/bin/llama-profile {fast\|medium\|long}` |
|
||||
| Router-Port | **8081** |
|
||||
| Router-Service | `mike-ai-profile-router.service` |
|
||||
| TTS-Worker | `http://127.0.0.1:8085` (Service `mike-ai-xtts.service`) |
|
||||
| STT-Worker | `http://127.0.0.1:8084` (Service `mike-ai-whisper.service`) |
|
||||
|
||||
## Profile / virtuelle Modelle
|
||||
|
||||
| Profil | Modell | Kontext |
|
||||
|---|---|---|
|
||||
| `fast` | `qwen-fast` | 76800 |
|
||||
| `medium` | `qwen-medium` | 94208 |
|
||||
| `long` | `qwen-long` | 131072 |
|
||||
|
||||
## Endpunkte
|
||||
|
||||
| Endpunkt | Beschreibung |
|
||||
|---|---|
|
||||
| `GET /v1/models` | Die drei virtuellen Modelle inkl. `context_length`/`context_window` |
|
||||
| `GET /health` | öffentliche Liveness-Prüfung des Routerprozesses |
|
||||
| `GET /ready` | öffentliche Readiness-Prüfung von Router + Textmodell |
|
||||
| `GET /status` | Aktives Profil, Upstream-Zustand, Modell, Kontext, Uptime |
|
||||
| `POST /fast` `/medium` `/long` | expliziter Profilwechsel |
|
||||
| `POST /v1/chat/completions` | Weiterleitung an llama.cpp (Streaming + Tool Calls) |
|
||||
| `POST /v1/images/generations` | Bildgenerierung (FLUX.2 [klein] 4B Base, OpenAI-kompatibel) |
|
||||
| `GET /images` | Liste der aufbewahrten Bilder |
|
||||
| `GET /images/<datei>` | PNG-Download (nur `images/`-Verzeichnis, validiert) |
|
||||
| `POST /v1/audio/speech` | Sprachausgabe (XTTS-v2, OpenAI-kompatibel) |
|
||||
| `POST /v1/audio/transcriptions` | Deutsche Spracherkennung (whisper.cpp, OpenAI-kompatibel) |
|
||||
| `GET /v1/audio/models` | Verfügbare Audio-Modelle (STT + TTS) |
|
||||
| `GET /v1/audio/voices` | Verfügbare TTS-Stimmen |
|
||||
| alles andere | Transparente Weiterleitung an llama.cpp |
|
||||
|
||||
Bis auf `GET /health` und `GET /ready` benötigen alle Endpunkte einen
|
||||
Router-API-Key als `Authorization: Bearer …` oder `X-API-Key`. Der Installer
|
||||
erzeugt ihn einmalig in `/etc/mike-ai/router-api-key` und gibt ihn niemals im
|
||||
Installationslog aus. Ein fehlender oder zu kurzer Schlüssel verhindert den
|
||||
Produktivstart (fail closed).
|
||||
|
||||
### Verhalten
|
||||
|
||||
- **Virtuelles Modell** (`qwen-fast`/`qwen-medium`/`qwen-long` in
|
||||
`chat.completions`): Der Router stellt sicher, dass das passende Profil
|
||||
aktiv ist (notfalls Wechsel + Warten), ersetzt das Modell durch das echte
|
||||
llama.cpp-Modell und leitet weiter.
|
||||
- **Profilwechsel** (`POST /fast` …): Führt `/usr/local/bin/llama-profile
|
||||
<profil>` aus (ohne Shell, feste Argumente → keine Injection), wartet dann,
|
||||
bis llama.cpp wieder erreichbar ist, und liefert erst dann `200`.
|
||||
- **Impliziter Wechsel bei downem llama.cpp**: Ein Chat-Request mit virtuellem
|
||||
Modell liefert sofort `502`, wenn das Profil bereits aktiv ist, aber
|
||||
llama.cpp down ist (kein stiller Neustart). Der Neustart wird explizit über
|
||||
`POST /<profil>` angestoßen.
|
||||
- **Ungültige Profile/Modelle**: `POST /<anderes>` → `400`;
|
||||
`qwen-<anderes>` als Modell → `400`. Nur die drei festen Profile sind
|
||||
schaltbar.
|
||||
- **Fehlerformat**: OpenAI-kompatibel (`{"error": {"message", "type", "code"}}`).
|
||||
|
||||
## Bildgenerierung (FLUX.2 [klein] 4B Base)
|
||||
|
||||
Der Router orchestriert lokale Bildgenerierung mit
|
||||
`black-forest-labs/FLUX.2-klein-base-4B` (Apache 2.0, ~13 GB, bf16 +
|
||||
CPU-Offload). Da Qwen (llama.cpp) und FLUX denselben GPU/VRAM teilen, macht
|
||||
der Router einen **GPU-Hotswap**:
|
||||
|
||||
1. Zentrales GPU/Modell-Lock übernehmen (Profilwechsel und Bild teilen sich
|
||||
dasselbe Lock → kein Race).
|
||||
2. Aktives Qwen-Profil merken.
|
||||
3. `mike-ai-llama-ui.service` stoppen, warten bis Port + VRAM frei sind.
|
||||
4. FLUX-Worker starten (eigener Prozess, `Flux2KleinPipeline`, bf16 +
|
||||
`enable_model_cpu_offload()`), Bild generieren, PNG speichern.
|
||||
5. Worker **beenden** (nicht nur entladen), VRAM-Freiheit verifizieren.
|
||||
6. Vorheriges Qwen-Profil exakt wiederherstellen, Readiness-Check
|
||||
(Modell geladen + Kontext passt).
|
||||
7. Erst dann antworten und das GPU-Lock freigeben.
|
||||
|
||||
### Endpunkt `POST /v1/images/generations`
|
||||
|
||||
OpenAI-kompatibel. Unterstützt `prompt`, `size`, `n`, `seed`, `quality`,
|
||||
`response_format`.
|
||||
|
||||
| Parameter | Werte | Default |
|
||||
|---|---|---|
|
||||
| `prompt` | Text (Pflicht) | – |
|
||||
| `size` | `1024x1024`, `1536x1024`, `1024x1536`, `1920x1088`, `1088x1920` | `1024x1024` |
|
||||
| `n` | 1–4 | 1 |
|
||||
| `seed` | int (reproduzierbar) | zufällig |
|
||||
| `quality` | `standard` (30 Steps), `high` (50 Steps) | `standard` |
|
||||
| `response_format` | `url` (Default), `b64_json` | `url` |
|
||||
|
||||
Beispiel:
|
||||
|
||||
```bash
|
||||
curl -s http://AI_HOST:8081/v1/images/generations \
|
||||
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"prompt":"ein roter Würfel auf weißem Grund","size":"1024x1024","quality":"standard"}'
|
||||
```text
|
||||
install.sh kompletter Bootstrap
|
||||
config/install.env.example öffentliche Konfigurationsvorlage
|
||||
compose.yaml produktiver Stack
|
||||
platform/docker/llama-cpp/Dockerfile CUDA-llama.cpp-Build
|
||||
platform/docker/profile-controller/ sichere Profilsteuerung
|
||||
router/ OpenAI-kompatibler Profile Router
|
||||
```
|
||||
|
||||
Die Antwort enthält `data[].url` (absolute URL, über den Router abrufbar)
|
||||
und `data[].b64_json` (optional). Jedes Bild wird unter
|
||||
`/opt/mike-ai/ai-profile-router/images/` gespeichert (kollisionsfreie Namen,
|
||||
`img-<zeitstempel>-<seed>-<i>.png`) und ist über `GET /images/<datei>`
|
||||
abrufbar.
|
||||
|
||||
### Verhalten während eines Bild-Jobs
|
||||
|
||||
- **Chat-Requests warten** (kein 502): Der Router merkt sich, dass Qwen
|
||||
vorübergehend nicht verfügbar ist (`qwen.available=false`), und Chat-Requests
|
||||
warten, bis Qwen wieder bereit ist (Timeout `CHAT_WAIT_TIMEOUT`, Default
|
||||
300 s). So gibt es keine `502 llama.cpp nicht erreichbar` während des
|
||||
Hotswaps.
|
||||
- **Profilwechsel warten**: Ein Profilwechsel während eines Bild-Jobs
|
||||
blockiert auf dem GPU-Lock, bis der Bild-Job fertig ist (kein Race).
|
||||
- **`/status`** zeigt den aktuellen Zustand: `image.phase` (`idle`,
|
||||
`stopping-qwen`, `loading-image`, `generating`, `unloading-image`,
|
||||
`restoring-qwen`), `image.worker`, `image.model_loaded`,
|
||||
`image.last_image`, `image.last_seconds`, `image.last_error`,
|
||||
`qwen.available`, `qwen.active_chats`.
|
||||
|
||||
### Recovery (robust)
|
||||
|
||||
- **`try/finally`**: Qwen wird **immer** wiederhergestellt, egal ob die
|
||||
Bildgenerierung erfolgreich war, fehlgeschlagen ist (OOM, Python-Fehler,
|
||||
ungültiger Prompt, Speichern-Fehler, Client-Disconnect, Timeout) oder der
|
||||
Worker abstürzt.
|
||||
- **Worker-Beendigung**: Nach jedem Job wird der Worker beendet (SIGTERM →
|
||||
SIGKILL), nicht nur entladen. So wird der VRAM (inkl. CUDA-Kontext) frei.
|
||||
- **VRAM-Check**: Nach dem Worker-Beenden wartet der Router, bis der VRAM
|
||||
unter 1000 MiB fällt (`nvidia-smi`), bevor Qwen neu startet.
|
||||
- **Qwen-Readiness**: Nach dem Neustart wartet der Router, bis llama.cpp
|
||||
erreichbar ist, das Modell geladen ist und der Kontext zum Profil passt.
|
||||
- **`qwen.available`**: Bleibt `false`, wenn die Wiederherstellung fehlschlägt
|
||||
(Chat-Requests warten weiter, statt 502 zu liefern). Der Fehler wird in
|
||||
`image.last_error` und im Log protokolliert.
|
||||
|
||||
### Benchmarks (RTX 5080, 16 GB, CPU-Offload, gemessen)
|
||||
|
||||
| Auflösung | Steps | Zeit | Peak-VRAM (torch) |
|
||||
|---|---|---|---|
|
||||
| 512×512 | 10 | ~9.3 s | ~8.4 GB |
|
||||
| 1024×1024 | 30 | ~31.3 s | ~8.4 GB |
|
||||
| 1024×1024 | 50 | ~45.3 s | ~8.4 GB |
|
||||
| 1920×1088 | 50 | ~91 s | ~8.9 GB |
|
||||
|
||||
**Entscheidung:** `standard` = 30 Steps (Default, ~31 s bei 1024×1024),
|
||||
`high` = 50 Steps (maximale Qualität, ~45 s bei 1024×1024). Ab 20–30 Steps
|
||||
ist der Qualitätsgewinn bei einfachen Motiven gering; 50 Steps lohnt sich
|
||||
für komplexe Szenen.
|
||||
|
||||
**Hinweis:** FLUX.2 [klein] 4B Base passt **nicht** vollständig GPU-resident
|
||||
in 16 GB (OOM bei ~15.5 GB). Deshalb wird `enable_model_cpu_offload()`
|
||||
verwendet (Modelle werden pro Layer zwischen CPU und GPU gewechselt).
|
||||
|
||||
**Hotswap-Gesamtzeit:** Ein vollständiger Bild-Job (Qwen stoppen → FLUX laden
|
||||
→ Bild → FLUX entladen → Qwen wiederherstellen) dauert ~41–42 s bei
|
||||
1024×1024 / 30 Steps (davon ~31 s Generierung, ~10 s Qwen-Stop/Start +
|
||||
VRAM-Check).
|
||||
|
||||
### Erforderliche Python-Pakete (im Venv)
|
||||
|
||||
- `torch` (2.11.0+cu128, CUDA 12.8)
|
||||
- `diffusers` (0.40.0.dev0, für `Flux2KleinPipeline`)
|
||||
- `transformers` (5.15.0)
|
||||
- `accelerate` (1.14.0, für `enable_model_cpu_offload()`)
|
||||
|
||||
Das Venv liegt unter `/opt/mike-ai/ai-profile-router/venv/` und wird von
|
||||
`install.sh` automatisch angelegt/aktualisiert.
|
||||
|
||||
## Integrierte Vision
|
||||
|
||||
Alle drei Qwen-Profile laden denselben BF16-Multimodalprojektor direkt beim
|
||||
Start. Der Projektor bleibt mit `--no-mmproj-offload` im System-RAM und
|
||||
verbraucht dadurch keinen zusätzlichen VRAM. Bilder in
|
||||
`POST /v1/chat/completions` werden nach Größen- und URL-Prüfung unverändert an
|
||||
das aktive Qwen-Profil weitergereicht. Es gibt kein separates Q3-Modell, keinen
|
||||
Vision-Port, keinen Analyse-Cache und keinen Modellwechsel mehr. Folgefragen
|
||||
bleiben dadurch im normalen multimodalen Chatverlauf.
|
||||
|
||||
Externe Bild-URLs sind standardmäßig gesperrt; Data-URLs dürfen dekodiert
|
||||
höchstens 20 MiB groß sein. Die FLUX-Bildgenerierung bleibt ein separater
|
||||
GPU-Hotswap und ist von dieser Änderung nicht betroffen.
|
||||
|
||||
## Sprachausgabe (XTTS-v2, CPU-only)
|
||||
|
||||
Der Router stellt lokale Sprachausgabe bereit. Die Synthese läuft
|
||||
in einem **separaten, langlebigen Worker** (`mike-ai-xtts.service`), der
|
||||
das XTTS-v2-Modell einmalig beim Start lädt und dauerhaft im RAM hält
|
||||
(niedrige Warm-Start-Latenz). Der Worker ist CPU-only und blockiert weder
|
||||
Qwen/llama.cpp noch FLUX/GPU – er teilt sich nur den Prozessor.
|
||||
|
||||
- **Modell:** Coqui XTTS-v2 (`tts_models/multilingual/multi-dataset/xtts_v2`,
|
||||
~1.9 GB, CPML-Lizenz).
|
||||
- **Stimme:** `claribel` (Claribel Dervla) – natürliche weibliche Stimme,
|
||||
unterstützt Deutsch und Englisch.
|
||||
- **Venv:** eigenes Venv unter `/opt/mike-ai/xtts/venv/` mit Python 3.11
|
||||
(Coqui TTS unterstützt kein Python 3.13) und CPU-only `torch`.
|
||||
- **Modelle:** HuggingFace-Cache unter `/opt/mike-ai/xtts/.cache/` (persistent).
|
||||
|
||||
### Endpunkt `POST /v1/audio/speech`
|
||||
|
||||
OpenAI-kompatibel. Unterstützt `input` (oder `text`), `voice`, `speed`,
|
||||
`response_format`, `model`.
|
||||
|
||||
| Parameter | Werte | Default |
|
||||
|---|---|---|
|
||||
| `input` | Text (Pflicht, max. 8000 Zeichen) | – |
|
||||
| `voice` | `claribel` | `claribel` |
|
||||
| `speed` | 0.5–2.0 | 1.0 |
|
||||
| `response_format` | `mp3` (Default), `wav` | `mp3` |
|
||||
| `model` | `xtts-v2` (optional) | – |
|
||||
|
||||
Die Antwort ist **binäres Audio** (nicht JSON) mit passendem
|
||||
`Content-Type` (`audio/mpeg`, `audio/wav`).
|
||||
|
||||
Beispiele:
|
||||
|
||||
```bash
|
||||
# MP3 (Default), Stimme claribel
|
||||
curl -s http://AI_HOST:8081/v1/audio/speech \
|
||||
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Hallo, dies ist ein Test.","voice":"claribel"}' -o out.mp3
|
||||
|
||||
# WAV, 1.5x Tempo
|
||||
curl -s http://AI_HOST:8081/v1/audio/speech \
|
||||
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Guten Tag.","voice":"claribel","speed":1.5,"response_format":"wav"}' -o out.wav
|
||||
```
|
||||
|
||||
**Lange Texte:** XTTS-v2 verarbeitet den Text intern in Sätze.
|
||||
Längere Texte werden automatisch in Segmente geteilt;
|
||||
jedes Segment wird einzeln synthetisiert und die Audios zusammengeführt.
|
||||
|
||||
### Verhalten
|
||||
|
||||
- **Kein GPU-Lock:** TTS läuft CPU-only und greift nicht in den
|
||||
GPU-Hotswap (Bild) oder Profilwechsel (Qwen) ein. TTS-Requests können
|
||||
parallel zu Chats laufen.
|
||||
- **Serialisierte Synthese:** Der Worker synthetisiert nacheinander
|
||||
(CPU-bound), parallele Requests werden intern gewartet.
|
||||
- **`/status`** zeigt `tts.reachable`, `tts.ready`, `tts.voices`,
|
||||
`tts.last_seconds`, `tts.last_voice`, `tts.last_error`.
|
||||
- **Fehler:** Worker down → `503` (`tts_failed`); ungültige Parameter →
|
||||
`400`. OpenAI-kompatibles Fehlerformat.
|
||||
|
||||
### Benchmark (CPU-only, gemessen)
|
||||
|
||||
| Textlänge | Audio | Synthese | RTF |
|
||||
|---|---|---|---|
|
||||
| ~6 s | 6.2 s | 8.2 s | 1.32× |
|
||||
| ~15 s | 15.0 s | 19.8 s | 1.32× |
|
||||
|
||||
RTF ~1.32 bedeutet: Synthese ist ~1.3× langsamer als Echtzeit.
|
||||
RAM-Belegung des Workers: ~4.5 GB (inkl. torch, XTTS-v2-Modell).
|
||||
|
||||
### Erforderliche Python-Pakete (im XTTS-Venv)
|
||||
|
||||
- `torch` (CPU-only, `--index-url https://download.pytorch.org/whl/cpu`)
|
||||
- `torchaudio` (CPU-only)
|
||||
- `TTS` (0.22.0, Coqui XTTS)
|
||||
- `transformers` (4.40.2, für `BeamSearchScorer`)
|
||||
- `tokenizers` (0.19.1)
|
||||
- `huggingface-hub` (0.36.2)
|
||||
- `librosa` (Audio-Verarbeitung)
|
||||
- `soundfile` (WAV-Export)
|
||||
|
||||
Das Venv liegt unter `/opt/mike-ai/xtts/venv/` und wird von `install.sh`
|
||||
automatisch angelegt (nur wenn noch nicht vorhanden).
|
||||
|
||||
**Hinweis (Python 3.11):** Coqui TTS 0.22.0 unterstützt offiziell nur
|
||||
Python `>=3.9.0, <3.12`. Daher wird Python 3.11.16 via `uv` verwendet.
|
||||
Der Zielsystem-Python (3.13.5) wird nicht verwendet.
|
||||
|
||||
**Hinweis (PyTorch 2.6+):** Coqui TTS nutzt `torch.load()` ohne
|
||||
`weights_only=False`, was in PyTorch 2.6+ standardmäßig fehlschlägt.
|
||||
Dies wird durch einen Patch in `TTS/utils/io.py` umgangen.
|
||||
|
||||
## Spracherkennung (whisper.cpp, deutsch, CPU-only)
|
||||
|
||||
Der Router stellt lokale deutsche Spracherkennung bereit. Die Transkription
|
||||
läuft in einem **separaten, langlebigen Worker** (`mike-ai-whisper.service`),
|
||||
der `whisper-cli` als Subprozess aufruft. Der Worker ist CPU-only und
|
||||
blockiert weder Qwen/llama.cpp noch FLUX/GPU – er teilt sich nur den
|
||||
Prozessor.
|
||||
|
||||
- **Modell:** Whisper large-v3-turbo (ggml, ~1.6 GB, Vollpräzision)
|
||||
- **Build:** whisper.cpp CPU-only (AVX2+FMA, 8 Threads)
|
||||
- **Audio-Vorbereitung:** ffmpeg konvertiert WebM/Opus/M4A/AAC → 16 kHz mono WAV
|
||||
- **Nativ unterstützt:** WAV, MP3, OGG, FLAC
|
||||
- **Kein GPU-Lock:** STT läuft vollständig auf CPU, parallel zu Qwen (GPU)
|
||||
und TTS (CPU)
|
||||
|
||||
### Endpunkt `POST /v1/audio/transcriptions`
|
||||
|
||||
OpenAI-kompatibel (Multipart-Form-Data). Unterstützt `file`, `model`,
|
||||
`language`, `prompt`, `temperature`, `response_format`.
|
||||
|
||||
| Parameter | Werte | Default |
|
||||
|---|---|---|
|
||||
| `file` | Audio-Datei (Pflicht: webm, wav, mp3, m4a, ogg, flac) | – |
|
||||
| `model` | `whisper-1` (oder `whisper`) | `whisper-1` |
|
||||
| `language` | `de`, `en`, … (optional) | Auto-Detektion |
|
||||
| `prompt` | Kontext-Hinweis (optional) | – |
|
||||
| `temperature` | 0.0–1.0 (optional) | 0.0 |
|
||||
| `response_format` | `json` (Default), `verbose_json` | `json` |
|
||||
|
||||
Die Antwort ist **JSON** mit `text` (und optional `language`, `duration`
|
||||
bei `verbose_json`).
|
||||
|
||||
Beispiele:
|
||||
|
||||
```bash
|
||||
# WebM/Opus (z.B. aus Open WebUI-Mikrofon)
|
||||
curl -s http://AI_HOST:8081/v1/audio/transcriptions \
|
||||
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||
-F "file=@aufnahme.webm" \
|
||||
-F "model=whisper-1"
|
||||
|
||||
# WAV mit expliziter Sprache
|
||||
curl -s http://AI_HOST:8081/v1/audio/transcriptions \
|
||||
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||
-F "file=@aufnahme.wav" \
|
||||
-F "model=whisper-1" \
|
||||
-F "language=de"
|
||||
|
||||
# Verbose-Format
|
||||
curl -s http://AI_HOST:8081/v1/audio/transcriptions \
|
||||
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||
-F "file=@aufnahme.wav" \
|
||||
-F "model=whisper-1" \
|
||||
-F "response_format=verbose_json"
|
||||
```
|
||||
|
||||
### Discovery-Endpunkte
|
||||
|
||||
- `GET /v1/audio/models` – listet verfügbare Audio-Modelle
|
||||
(`whisper-1` für STT, `xtts-v2` für TTS)
|
||||
- `GET /v1/audio/voices` – listet verfügbare TTS-Stimmen
|
||||
(`claribel`)
|
||||
|
||||
### Verhalten
|
||||
|
||||
- **Kein GPU-Lock:** STT läuft CPU-only und greift nicht in den
|
||||
GPU-Hotswap (Bild) oder Profilwechsel (Qwen) ein. STT-Requests können
|
||||
parallel zu Chats, TTS und Bildgenerierung laufen.
|
||||
- **Serialisierte Transkription:** Der Worker transkribiert nacheinander
|
||||
(CPU-bound), parallele Requests werden intern gewartet.
|
||||
- **`/status`** zeigt `stt.reachable`, `stt.ready`, `stt.model`,
|
||||
`stt.threads`, `stt.language`, `stt.ffmpeg_exists`.
|
||||
- **Fehler:** Worker down → `503` (`stt_failed`); ungültige Parameter →
|
||||
`400`. OpenAI-kompatibles Fehlerformat.
|
||||
|
||||
### Benchmark (CPU-only, gemessen)
|
||||
|
||||
| Audio-Dauer | Transkription | RTF |
|
||||
|---|---|---|
|
||||
| 7.3 s | 8.9 s | 1.22× |
|
||||
| 30 s | 16.8 s | 0.56× |
|
||||
| 50 s | 18.5 s | 0.37× |
|
||||
|
||||
RTF < 1.0 bedeutet: Transkription ist schneller als Echtzeit.
|
||||
RAM-Belegung des Workers: ~1.7 GB (inkl. Modell).
|
||||
|
||||
### Erforderliche Komponenten
|
||||
|
||||
- `whisper.cpp` (CPU-only Build, `/opt/mike-ai/whisper.cpp/build-cpu/`)
|
||||
- `ggml-large-v3-turbo.bin` (`/opt/mike-ai/models/whisper/`)
|
||||
- `ffmpeg` (für WebM/Opus/M4A/AAC-Konvertierung)
|
||||
- Python 3.13 (nur Standardbibliothek, kein Venv nötig)
|
||||
|
||||
## Repository-Struktur
|
||||
|
||||
```
|
||||
router/ai_profile_router.py # der Router (einzige Laufzeit-Datei)
|
||||
router/image_worker.py # FLUX-Worker (eigener Prozess, JSON-Protokoll)
|
||||
router/xtts_worker.py # XTTS-v2-TTS-Worker (eigener Prozess, HTTP-API)
|
||||
router/stt_worker.py # Whisper-STT-Worker (eigener Prozess, HTTP-API)
|
||||
deploy/mike-ai-profile-router.service # systemd-Unit (Router)
|
||||
deploy/mike-ai-xtts.service # systemd-Unit (TTS-Worker)
|
||||
deploy/mike-ai-whisper.service # systemd-Unit (STT-Worker)
|
||||
deploy/install.sh # läuft auf dem Zielsystem (per SSH)
|
||||
deploy/deploy.sh # läuft lokal: SCP + SSH
|
||||
dev/ # lokale Tests (Mock-llama.cpp, Mock-Worker, Benchmarks)
|
||||
```
|
||||
|
||||
Entwicklungsdateien (`dev/`) und Deployment-Dateien (`router/`, `deploy/`)
|
||||
sind getrennt. Auf dem Zielsystem landet nur `router/` + `deploy/`.
|
||||
|
||||
## Deployment
|
||||
|
||||
Voraussetzung: administrativer SSH-Key für den Zielhost. Ziel und Key werden
|
||||
explizit über `TARGET` und `SSH_KEY` übergeben.
|
||||
|
||||
```bash
|
||||
./deploy/deploy.sh
|
||||
```
|
||||
|
||||
Das Skript:
|
||||
|
||||
1. Überträgt `ai_profile_router.py`, `image_worker.py`, `tts_worker.py`,
|
||||
`install.sh` und beide systemd-Units per SCP nach
|
||||
`/tmp/ai-profile-router/` auf dem Zielsystem.
|
||||
2. Führt `install.sh` per SSH aus, das:
|
||||
- den alten Router (`mike-ai-local-llm-router.service` +
|
||||
`/opt/mike-ai/local-llm-router`) **mit Backup** entfernt,
|
||||
- den neuen Router + Worker nach `/opt/mike-ai/ai-profile-router/`
|
||||
installiert,
|
||||
- ein Python-Venv mit `torch`, `diffusers`, `transformers`, `accelerate`
|
||||
anlegt (nur wenn noch nicht vorhanden),
|
||||
- das FLUX-Modell nach `/opt/mike-ai/models/FLUX.2-klein-base-4B` lädt
|
||||
(nur wenn noch nicht vorhanden, ~15 GB),
|
||||
- `espeak-ng` installiert (nur wenn noch nicht vorhanden),
|
||||
- das XTTS-Venv unter `/opt/mike-ai/xtts/venv/` anlegt (nur wenn
|
||||
noch nicht vorhanden, Python 3.11 via uv + CPU-only torch + Coqui TTS),
|
||||
- das XTTS-v2-Modell herunterlädt (nur wenn noch nicht vorhanden, ~1.9 GB),
|
||||
- `mike-ai-profile-router.service` und `mike-ai-xtts.service`
|
||||
aktivieren (Start beim Boot) und starten,
|
||||
- `GET /status` verifiziert.
|
||||
|
||||
Die Installation ist idempotent (Update = erneut ausführen).
|
||||
|
||||
## Konfiguration (Umgebungsvariablen in der systemd-Unit)
|
||||
|
||||
| Variable | Default | Bedeutung |
|
||||
|---|---|---|
|
||||
| `ROUTER_HOST` | `0.0.0.0` | Bind-Adresse |
|
||||
| `ROUTER_PORT` | `8081` | Port |
|
||||
| `ROUTER_AUTH_MODE` | `required` | Authentifizierung; `off` nur für lokale Tests |
|
||||
| `ROUTER_API_KEY_FILE` | `/etc/mike-ai/router-api-key` | Schlüsseldatei (0600) |
|
||||
| `ROUTER_STATE_FILE` | `/var/lib/mike-ai-profile-router/state.json` | atomarer Crash-/Recovery-Zustand |
|
||||
| `ROUTER_PROFILES_FILE` | `/etc/mike-ai/router-profiles.json` | Profile, Kontext und erwarteter Alias |
|
||||
| `ROUTER_MAX_CONCURRENT_REQUESTS` | `16` | harte Grenze paralleler Requests |
|
||||
| `UPSTREAM_URL` | `http://127.0.0.1:8080` | llama.cpp |
|
||||
| `PROFILE_SCRIPT` | `/usr/local/bin/llama-profile` | Profil-Skript |
|
||||
| `PROFILE_DIR` | `/etc/systemd/system/mike-ai-llama-ui.service.d` | Ort der `override.conf` |
|
||||
| `SWITCH_TIMEOUT` | `600` | Warten auf llama.cpp nach Wechsel (s) |
|
||||
| `REQUEST_TIMEOUT` | `600` | Read-Timeout für Upstream-Requests (s) |
|
||||
| `CONNECT_TIMEOUT` | `10` | Connect-Timeout Upstream (s) |
|
||||
| `POLL_INTERVAL` | `2` | Polling-Intervall (s) |
|
||||
| `LOG_LEVEL` | `INFO` | Logging-Level |
|
||||
| `LLAMA_SERVICE` | `mike-ai-llama-ui.service` | llama.cpp-Service (für Bild-Hotswap) |
|
||||
| `IMAGE_WORKER` | `<router-dir>/image_worker.py` | FLUX-Worker-Skript |
|
||||
| `IMAGE_PYTHON` | `sys.executable` | Python für den Worker (venv mit torch) |
|
||||
| `IMAGE_DIR` | `/opt/mike-ai/ai-profile-router/images` | Bild-Speicherort |
|
||||
| `IMAGE_WORKER_LOG` | `/opt/mike-ai/ai-profile-router/worker.log` | Worker-Log |
|
||||
| `IMAGE_GEN_TIMEOUT` | `600` | Timeout pro Bild (s) |
|
||||
| `IMAGE_VRAM_FREE_TIMEOUT` | `120` | Warten auf VRAM-Freiheit (s) |
|
||||
| `CHAT_WAIT_TIMEOUT` | `300` | Chat wartet auf Qwen (s) |
|
||||
| `TTS_WORKER_URL` | `http://127.0.0.1:8085` | TTS-Worker (Router-Seite) |
|
||||
| `TTS_TIMEOUT` | `300` | Timeout pro Synthese (s) |
|
||||
| `TTS_CONNECT_TIMEOUT` | `5` | Connect-Timeout TTS-Worker (s) |
|
||||
| `CHAT_IMAGE_MAX_BYTES` | `20971520` | maximales dekodiertes Chatbild (20 MiB) |
|
||||
| `CHAT_IMAGE_ALLOW_REMOTE_URLS` | `false` | externe Bild-URLs; standardmäßig SSRF-sicher aus |
|
||||
|
||||
TTS-Worker (`mike-ai-xtts.service`):
|
||||
|
||||
| Variable | Default | Bedeutung |
|
||||
|---|---|---|
|
||||
| `XTTS_HOST` | `127.0.0.1` | Bind-Adresse (nur lokal, Router proxyt) |
|
||||
| `XTTS_PORT` | `8085` | Port |
|
||||
| `XTTS_MODEL_ID` | `tts_models/multilingual/multi-dataset/xtts_v2` | Modell-ID |
|
||||
| `XTTS_DEFAULT_VOICE` | `claribel` | Default-Stimme |
|
||||
| `LOG_LEVEL` | `INFO` | Logging-Level |
|
||||
|
||||
## Lokale Tests
|
||||
|
||||
```bash
|
||||
./dev/test_local.sh
|
||||
```
|
||||
|
||||
Startet einen Mock-llama.cpp, einen Mock-Bild-Worker, einen Mock-TTS-Worker
|
||||
und den Router mit einem Fake-Profil-Skript und prüft: `/v1/models`,
|
||||
`/status`, Forwarding, Streaming, Tool Calls, Profilwechsel
|
||||
(fast→medium→fast), virtuelles Modell triggert Wechsel, ungültige Profile,
|
||||
Upstream down → 502, Recovery, **Bildgenerierung** (`standard`→30 Steps,
|
||||
`high`→50 Steps, Validierung, Image-Fehler→Qwen wiederhergestellt,
|
||||
Fast/Medium/Long→Image→gleiches Profil, `/status` während Bild-Job,
|
||||
paralleler Chat während Bild-Job wartet statt 502) und **Sprachausgabe**
|
||||
(`/status` mit tts-Section, `POST /v1/audio/speech` wav/mp3, Validierung,
|
||||
Worker-Fehler→503, Worker down→503, Worker-Neustart→Recovery).
|
||||
|
||||
Aktuell: **63 Integrationsassertions** plus Chunked-, Multipart- und
|
||||
Security/Recovery-Unit-Tests.
|
||||
|
||||
## Betrieb
|
||||
|
||||
```bash
|
||||
systemctl status mike-ai-profile-router
|
||||
systemctl status mike-ai-xtts
|
||||
journalctl -u mike-ai-profile-router -f
|
||||
journalctl -u mike-ai-xtts -f
|
||||
ROUTER_KEY='aus lokalem Secret-Store'
|
||||
curl -s http://AI_HOST:8081/status -H "Authorization: Bearer $ROUTER_KEY" | python3 -m json.tool
|
||||
curl -s -X POST http://AI_HOST:8081/medium -H "Authorization: Bearer $ROUTER_KEY"
|
||||
# TTS-Test
|
||||
curl -s http://AI_HOST:8081/v1/audio/speech \
|
||||
-H "Authorization: Bearer $ROUTER_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Hallo","voice":"claribel"}' -o test.mp3
|
||||
```
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Keine Shell-Aufrufe: Profil-Skript wird mit `subprocess.run([script, profil])`
|
||||
aufgerufen, `profil` ist Whitelist-geprüft (`fast|medium|long`).
|
||||
- Keine Secrets/Tokens im Code oder in der Unit.
|
||||
- API-Key-Pflicht, konstante Schlüsselprüfung und keine Weitergabe des
|
||||
Router-Schlüssels an llama.cpp.
|
||||
- Remote-Bilder standardmäßig gesperrt; lokale Data-URLs sind typ- und
|
||||
größenvalidiert.
|
||||
- systemd-Hardening: `NoNewPrivileges`, `PrivateTmp`, `ProtectHome`,
|
||||
Kernel-/Control-Group-Schutz und restriktive Dateirechte.
|
||||
- Die bestehende llama.cpp-/Profil-Konfiguration wird nicht verändert; der
|
||||
Router nutzt nur das vorhandene `llama-profile`-Skript und liest die
|
||||
`override.conf`.
|
||||
## Sicherheitsregeln
|
||||
|
||||
- `config/install.env` ist lokal, Modus 0600, und wird ignoriert.
|
||||
- API-, Controller-, WebUI- und WireGuard-Schlüssel entstehen erst am Host.
|
||||
- Nur der kleine Profile Controller sieht den Docker-Socket.
|
||||
- llama.cpp veröffentlicht weder Port noch WebUI.
|
||||
- Ein Blackhole-Fallback verhindert Traffic-Leaks bei WireGuard-Ausfall.
|
||||
- Das Uni-Netz und das Heimnetz dürfen diesen Host nicht als Transit benutzen.
|
||||
|
||||
Die Profilwerte sind Ausgangswerte. Nach dem Neuaufbau werden RTX 5080 und
|
||||
RTX 3060 mit der bestehenden Standard-Testserie neu vermessen, bevor die zweite
|
||||
GPU in ein Produktionsprofil einfließt.
|
||||
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
name: mike-ai
|
||||
|
||||
x-llama-common: &llama-common
|
||||
image: ${LLAMA_IMAGE:-mike-ai/llama.cpp:local}
|
||||
restart: "no"
|
||||
profiles: [inference]
|
||||
gpus: all
|
||||
ipc: host
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=1g,mode=1777
|
||||
volumes:
|
||||
- "${MODEL_DIR:-/srv/mike-ai/models}:/models:ro"
|
||||
- ./platform/docker/mcp-standard.json:/etc/mike-ai/mcp-standard.json:ro
|
||||
environment:
|
||||
SEARXNG_URL: http://searxng:8080
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
networks:
|
||||
inference:
|
||||
aliases: [llama-upstream]
|
||||
search: {}
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: [ALL]
|
||||
healthcheck:
|
||||
test: [CMD, curl, -fsS, "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 60
|
||||
start_period: 30s
|
||||
|
||||
services:
|
||||
llama-fast:
|
||||
<<: *llama-common
|
||||
container_name: mike-ai-llama-fast
|
||||
labels:
|
||||
com.mike-ai.llama-profile: fast
|
||||
environment:
|
||||
SEARXNG_URL: http://searxng:8080
|
||||
NVIDIA_VISIBLE_DEVICES: ${FAST_GPU_DEVICES:-0}
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||
command:
|
||||
- --model
|
||||
- "/models/${FAST_MODEL_FILE:?FAST_MODEL_FILE is required}"
|
||||
- --mmproj
|
||||
- "/models/${VISION_PROJECTOR_FILE:?VISION_PROJECTOR_FILE is required}"
|
||||
- --no-mmproj-offload
|
||||
- --alias
|
||||
- qwen-fast
|
||||
- --ctx-size
|
||||
- "${FAST_CONTEXT:-76800}"
|
||||
- --flash-attn
|
||||
- "on"
|
||||
- --cache-type-k
|
||||
- q4_0
|
||||
- --cache-type-v
|
||||
- q4_0
|
||||
- --threads
|
||||
- "${LLAMA_THREADS:-6}"
|
||||
- --threads-batch
|
||||
- "${LLAMA_THREADS_BATCH:-6}"
|
||||
- --batch-size
|
||||
- "64"
|
||||
- --ubatch-size
|
||||
- "32"
|
||||
- --parallel
|
||||
- "1"
|
||||
- --jinja
|
||||
- --reasoning
|
||||
- auto
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8080"
|
||||
- --metrics
|
||||
- --fit
|
||||
- "off"
|
||||
- --n-gpu-layers
|
||||
- all
|
||||
- --no-mmap
|
||||
- --no-ui
|
||||
- --temperature
|
||||
- "0.2"
|
||||
- --top-p
|
||||
- "0.8"
|
||||
- --top-k
|
||||
- "20"
|
||||
- --mcp-servers-config
|
||||
- /etc/mike-ai/mcp-standard.json
|
||||
- --device
|
||||
- CUDA0
|
||||
- --split-mode
|
||||
- none
|
||||
- --spec-type
|
||||
- draft-mtp
|
||||
- --model-draft
|
||||
- "/models/${MTP_MODEL_FILE:?MTP_MODEL_FILE is required}"
|
||||
- --spec-draft-n-max
|
||||
- "2"
|
||||
- --spec-draft-type-k
|
||||
- f16
|
||||
- --spec-draft-type-v
|
||||
- f16
|
||||
|
||||
llama-medium:
|
||||
<<: *llama-common
|
||||
container_name: mike-ai-llama-medium
|
||||
labels:
|
||||
com.mike-ai.llama-profile: medium
|
||||
environment:
|
||||
SEARXNG_URL: http://searxng:8080
|
||||
NVIDIA_VISIBLE_DEVICES: ${MEDIUM_GPU_DEVICES:-0}
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||
command:
|
||||
- --model
|
||||
- "/models/${MEDIUM_MODEL_FILE:?MEDIUM_MODEL_FILE is required}"
|
||||
- --mmproj
|
||||
- "/models/${VISION_PROJECTOR_FILE:?VISION_PROJECTOR_FILE is required}"
|
||||
- --no-mmproj-offload
|
||||
- --alias
|
||||
- qwen-medium
|
||||
- --ctx-size
|
||||
- "${MEDIUM_CONTEXT:-94208}"
|
||||
- --flash-attn
|
||||
- "on"
|
||||
- --cache-type-k
|
||||
- q4_0
|
||||
- --cache-type-v
|
||||
- q4_0
|
||||
- --threads
|
||||
- "${LLAMA_THREADS:-6}"
|
||||
- --threads-batch
|
||||
- "${LLAMA_THREADS_BATCH:-6}"
|
||||
- --batch-size
|
||||
- "64"
|
||||
- --ubatch-size
|
||||
- "32"
|
||||
- --parallel
|
||||
- "1"
|
||||
- --jinja
|
||||
- --reasoning
|
||||
- auto
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8080"
|
||||
- --metrics
|
||||
- --fit
|
||||
- "off"
|
||||
- --n-gpu-layers
|
||||
- all
|
||||
- --no-mmap
|
||||
- --no-ui
|
||||
- --temperature
|
||||
- "0.2"
|
||||
- --top-p
|
||||
- "0.8"
|
||||
- --top-k
|
||||
- "20"
|
||||
- --mcp-servers-config
|
||||
- /etc/mike-ai/mcp-standard.json
|
||||
- --device
|
||||
- CUDA0
|
||||
- --split-mode
|
||||
- none
|
||||
|
||||
llama-long:
|
||||
<<: *llama-common
|
||||
container_name: mike-ai-llama-long
|
||||
labels:
|
||||
com.mike-ai.llama-profile: long
|
||||
environment:
|
||||
SEARXNG_URL: http://searxng:8080
|
||||
NVIDIA_VISIBLE_DEVICES: ${LONG_GPU_DEVICES:-0}
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||
command:
|
||||
- --model
|
||||
- "/models/${LONG_MODEL_FILE:?LONG_MODEL_FILE is required}"
|
||||
- --mmproj
|
||||
- "/models/${VISION_PROJECTOR_FILE:?VISION_PROJECTOR_FILE is required}"
|
||||
- --no-mmproj-offload
|
||||
- --alias
|
||||
- qwen-long
|
||||
- --ctx-size
|
||||
- "${LONG_CONTEXT:-131072}"
|
||||
- --flash-attn
|
||||
- "on"
|
||||
- --cache-type-k
|
||||
- q4_0
|
||||
- --cache-type-v
|
||||
- q4_0
|
||||
- --threads
|
||||
- "${LLAMA_THREADS:-6}"
|
||||
- --threads-batch
|
||||
- "${LLAMA_THREADS_BATCH:-6}"
|
||||
- --batch-size
|
||||
- "64"
|
||||
- --ubatch-size
|
||||
- "32"
|
||||
- --parallel
|
||||
- "1"
|
||||
- --jinja
|
||||
- --reasoning
|
||||
- auto
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8080"
|
||||
- --metrics
|
||||
- --fit
|
||||
- "off"
|
||||
- --n-gpu-layers
|
||||
- all
|
||||
- --override-tensor
|
||||
- blk.([0-9]|1[0-1]).ffn_.*=CPU
|
||||
- --no-mmap
|
||||
- --no-ui
|
||||
- --temperature
|
||||
- "0.2"
|
||||
- --top-p
|
||||
- "0.8"
|
||||
- --top-k
|
||||
- "20"
|
||||
- --mcp-servers-config
|
||||
- /etc/mike-ai/mcp-standard.json
|
||||
- --device
|
||||
- CUDA0
|
||||
- --split-mode
|
||||
- none
|
||||
- --spec-type
|
||||
- draft-mtp
|
||||
- --model-draft
|
||||
- "/models/${MTP_MODEL_FILE:?MTP_MODEL_FILE is required}"
|
||||
- --spec-draft-n-max
|
||||
- "2"
|
||||
- --spec-draft-type-k
|
||||
- q4_0
|
||||
- --spec-draft-type-v
|
||||
- q4_0
|
||||
|
||||
llama-experimental:
|
||||
<<: *llama-common
|
||||
container_name: mike-ai-llama-experimental
|
||||
labels:
|
||||
com.mike-ai.llama-profile: experimental
|
||||
environment:
|
||||
SEARXNG_URL: http://searxng:8080
|
||||
NVIDIA_VISIBLE_DEVICES: ${EXPERIMENTAL_GPU_DEVICES:-0}
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||
command:
|
||||
- --model
|
||||
- "/models/${EXPERIMENTAL_MODEL_FILE:?EXPERIMENTAL_MODEL_FILE is required}"
|
||||
- --alias
|
||||
- qwen-experimental
|
||||
- --ctx-size
|
||||
- "${EXPERIMENTAL_CONTEXT:-76800}"
|
||||
- --flash-attn
|
||||
- "on"
|
||||
- --cache-type-k
|
||||
- q4_0
|
||||
- --cache-type-v
|
||||
- q4_0
|
||||
- --parallel
|
||||
- "1"
|
||||
- --jinja
|
||||
- --reasoning
|
||||
- auto
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8080"
|
||||
- --metrics
|
||||
- --fit
|
||||
- "off"
|
||||
- --n-gpu-layers
|
||||
- all
|
||||
- --no-mmap
|
||||
- --no-ui
|
||||
- --mcp-servers-config
|
||||
- /etc/mike-ai/mcp-standard.json
|
||||
- --device
|
||||
- CUDA0
|
||||
- --split-mode
|
||||
- none
|
||||
|
||||
profile-controller:
|
||||
build: ./platform/docker/profile-controller
|
||||
image: mike-ai/profile-controller:local
|
||||
container_name: mike-ai-profile-controller
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:size=16m"]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
CONTROLLER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
||||
ALLOWED_PROFILES: fast,medium,long,experimental
|
||||
networks: [control]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
healthcheck:
|
||||
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=2)"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
router:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: platform/docker/router/Dockerfile
|
||||
image: mike-ai/profile-router:local
|
||||
container_name: mike-ai-router
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:size=256m"]
|
||||
volumes:
|
||||
- ./router/router_profiles.json:/etc/mike-ai/router-profiles.json:ro
|
||||
- router-state:/var/lib/mike-ai-profile-router
|
||||
- router-images:/data/images
|
||||
environment:
|
||||
ROUTER_HOST: 0.0.0.0
|
||||
ROUTER_PORT: "8081"
|
||||
ROUTER_AUTH_MODE: required
|
||||
ROUTER_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
|
||||
ROUTER_PROFILES_FILE: /etc/mike-ai/router-profiles.json
|
||||
ROUTER_STATE_FILE: /var/lib/mike-ai-profile-router/state.json
|
||||
ROUTER_MAX_CONCURRENT_REQUESTS: "16"
|
||||
UPSTREAM_URL: http://llama-upstream:8080
|
||||
PROFILE_CONTROL_URL: http://profile-controller:8090
|
||||
PROFILE_CONTROL_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
||||
SWITCH_TIMEOUT: "600"
|
||||
REQUEST_TIMEOUT: "600"
|
||||
IMAGE_DIR: /data/images
|
||||
CHAT_IMAGE_ALLOW_REMOTE_URLS: "false"
|
||||
ENABLE_IMAGE_GENERATION: "false"
|
||||
ENABLE_TTS: "false"
|
||||
ENABLE_STT: "false"
|
||||
ports:
|
||||
- "${AI_BIND_ADDRESS:-127.0.0.1}:8081:8081"
|
||||
networks: [frontend, control, inference]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: [ALL]
|
||||
depends_on:
|
||||
profile-controller:
|
||||
condition: service_healthy
|
||||
|
||||
open-webui:
|
||||
image: ${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.9.5}
|
||||
container_name: mike-ai-open-webui
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- open-webui-data:/app/backend/data
|
||||
environment:
|
||||
WEBUI_SECRET_KEY: "${WEBUI_SECRET_KEY:?WEBUI_SECRET_KEY is required}"
|
||||
OLLAMA_BASE_URL: ""
|
||||
OPENAI_API_BASE_URLS: http://router:8081/v1
|
||||
OPENAI_API_KEYS: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
|
||||
ENABLE_SIGNUP: ${OPENWEBUI_ENABLE_SIGNUP:-false}
|
||||
DO_NOT_TRACK: "true"
|
||||
SCARF_NO_ANALYTICS: "true"
|
||||
ports:
|
||||
- "${AI_BIND_ADDRESS:-127.0.0.1}:8080:8080"
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
networks: [frontend]
|
||||
depends_on: [router]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
searxng:
|
||||
image: searxng/searxng@sha256:e45d5894bfaa0bf8773b9f283795ae57f1c15ddb29c8cecb70b3665b0ce9ec60
|
||||
container_name: mike-ai-searxng
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./platform/web-search/searxng-settings.yml:/etc/searxng/settings.yml:ro
|
||||
networks: [search]
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: [ALL]
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
internal: false
|
||||
ipam:
|
||||
config: [{subnet: 172.30.10.0/24}]
|
||||
control:
|
||||
internal: true
|
||||
ipam:
|
||||
config: [{subnet: 172.30.20.0/24}]
|
||||
inference:
|
||||
internal: true
|
||||
ipam:
|
||||
config: [{subnet: 172.30.30.0/24}]
|
||||
search:
|
||||
internal: false
|
||||
ipam:
|
||||
config: [{subnet: 172.30.40.0/24}]
|
||||
|
||||
volumes:
|
||||
open-webui-data:
|
||||
router-state:
|
||||
router-images:
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copy to /root/mike-ai-install.env, edit, chmod 600, then run:
|
||||
# sudo ./install.sh --config /root/mike-ai-install.env
|
||||
|
||||
AI_HOSTNAME=ki-host
|
||||
ADMIN_USER=mike
|
||||
AI_BIND_ADDRESS=10.77.0.2
|
||||
MODEL_DIR=/srv/mike-ai/models
|
||||
|
||||
# Installing a new NVIDIA driver can require one reboot. In that case this
|
||||
# installer exits with code 20; rerun the same command after reboot.
|
||||
INSTALL_NVIDIA_DRIVER=true
|
||||
TEXT_GPU_DEVICES=0
|
||||
SECONDARY_GPU_DEVICES=1
|
||||
|
||||
# WireGuard client. The home peer must route 10.77.0.2/32 back to this host.
|
||||
WIREGUARD_ENABLE=true
|
||||
WG_INTERFACE=wg0
|
||||
WG_ADDRESS=10.77.0.2/32
|
||||
WG_HOME_SUBNET=192.168.1.0/24
|
||||
WG_PEER_PUBLIC_KEY=REPLACE_WITH_HOME_WIREGUARD_PUBLIC_KEY
|
||||
WG_PEER_ENDPOINT=home.example.net:51820
|
||||
WG_PEER_PRESHARED_KEY_FILE=
|
||||
WG_DNS=192.168.1.1
|
||||
WG_ROUTE_AI_INTERNET=true
|
||||
|
||||
# Exact model artifacts. Never put access tokens in these URLs.
|
||||
FAST_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
FAST_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
FAST_MODEL_SHA256=40fac4050e940397dbf13087afd50f4734a11805bf9d65ef8ddd7483470e6199
|
||||
MEDIUM_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
MEDIUM_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
MEDIUM_MODEL_SHA256=40fac4050e940397dbf13087afd50f4734a11805bf9d65ef8ddd7483470e6199
|
||||
LONG_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
LONG_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
LONG_MODEL_SHA256=40fac4050e940397dbf13087afd50f4734a11805bf9d65ef8ddd7483470e6199
|
||||
EXPERIMENTAL_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
EXPERIMENTAL_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf
|
||||
EXPERIMENTAL_MODEL_SHA256=40fac4050e940397dbf13087afd50f4734a11805bf9d65ef8ddd7483470e6199
|
||||
VISION_PROJECTOR_FILE=qwen/mmproj-BF16.gguf
|
||||
VISION_PROJECTOR_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/mmproj-BF16.gguf
|
||||
VISION_PROJECTOR_SHA256=83ee4f4f205fa514161778c41df1ea14144faa0f713510893b63c2395f5c2d53
|
||||
MTP_MODEL_FILE=qwen/mtp-Qwen3.8-27B-Q4_0.gguf
|
||||
MTP_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/MTP/mtp-Qwen3.8-27B-Q4_0.gguf
|
||||
MTP_MODEL_SHA256=50d9ce5a6da381bbcfb31061cf73df94a90e6faf8efeddee379a9cb8f1501c6e
|
||||
|
||||
FAST_CONTEXT=76800
|
||||
MEDIUM_CONTEXT=94208
|
||||
LONG_CONTEXT=131072
|
||||
EXPERIMENTAL_CONTEXT=76800
|
||||
LLAMA_THREADS=6
|
||||
LLAMA_THREADS_BATCH=6
|
||||
OPENWEBUI_IMAGE=ghcr.io/open-webui/open-webui:v0.9.5
|
||||
OPENWEBUI_ENABLE_SIGNUP=false
|
||||
@@ -0,0 +1,58 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import pathlib
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
os.environ.setdefault("CONTROLLER_TOKEN", "x" * 48)
|
||||
PATH = pathlib.Path(__file__).parents[1] / "platform/docker/profile-controller/profile_controller.py"
|
||||
SPEC = importlib.util.spec_from_file_location("profile_controller", PATH)
|
||||
controller = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC and SPEC.loader
|
||||
SPEC.loader.exec_module(controller)
|
||||
|
||||
|
||||
def item(profile, state="exited"):
|
||||
return {
|
||||
"Id": f"id-{profile}",
|
||||
"State": state,
|
||||
"Labels": {controller.LABEL_KEY: profile},
|
||||
}
|
||||
|
||||
|
||||
class ProfileControllerTests(unittest.TestCase):
|
||||
def test_rejects_unknown_profile_before_docker_call(self):
|
||||
with patch.object(controller, "docker_request") as request:
|
||||
with self.assertRaises(ValueError):
|
||||
controller.activate("shell")
|
||||
request.assert_not_called()
|
||||
|
||||
def test_switch_stops_running_profile_then_starts_target(self):
|
||||
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||
profiles["fast"] = item("fast", "running")
|
||||
calls = []
|
||||
|
||||
def request(method, path):
|
||||
calls.append((method, path))
|
||||
return 204, b""
|
||||
|
||||
with patch.object(controller, "containers", return_value=profiles), \
|
||||
patch.object(controller, "docker_request", side_effect=request):
|
||||
result = controller.activate("medium")
|
||||
|
||||
self.assertEqual(result, {"active_profile": "medium", "changed": True})
|
||||
self.assertEqual(calls, [
|
||||
("POST", "/containers/id-fast/stop?t=120"),
|
||||
("POST", "/containers/id-medium/start"),
|
||||
])
|
||||
|
||||
def test_fails_if_profile_container_is_missing(self):
|
||||
profiles = {name: item(name) for name in controller.ALLOWED[:-1]}
|
||||
with patch.object(controller, "containers", return_value=profiles):
|
||||
with self.assertRaisesRegex(RuntimeError, "missing"):
|
||||
controller.activate("fast")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+67
-92
@@ -1,107 +1,82 @@
|
||||
# Architektur
|
||||
# Zielarchitektur des neuen KI-Hosts
|
||||
|
||||
## Ziel
|
||||
## Grundsatz
|
||||
|
||||
Die Plattform stellt eine private, lokal betriebene OpenAI-kompatible API
|
||||
bereit. Jede Komponente hat genau eine Aufgabe und kann unabhängig ersetzt
|
||||
werden. Ein Neuaufbau darf keine Dateien vom alten Host voraussetzen, die nicht
|
||||
in diesem Repository oder im Modellmanifest beschrieben sind.
|
||||
Der Host läuft auf Debian 13. Das Betriebssystem darf im Universitätsnetz
|
||||
administrierbar bleiben; die KI-Plattform wird ausschließlich an die
|
||||
WireGuard-Adresse gebunden. KI-Container erreichen Heimnetz und Internet über
|
||||
den Heim-WireGuard-Peer. Bei Tunnelausfall verhindert eine Blackhole-Route den
|
||||
unbeabsichtigten Rückfall auf das Universitätsgateway.
|
||||
|
||||
## Komponenten
|
||||
```text
|
||||
Heimnetz / VPN-Clients
|
||||
|
|
||||
WireGuard
|
||||
|
|
||||
10.77.0.2:8080 Open WebUI
|
||||
10.77.0.2:8081 Profile Router API
|
||||
|
|
||||
Docker-intern
|
||||
+-- Profile Controller -- Docker Socket (feste Allowlist)
|
||||
+-- llama-fast --\
|
||||
+-- llama-medium > exakt einer aktiv
|
||||
+-- llama-long --/
|
||||
+-- llama-experimental
|
||||
+-- SearXNG + Web-MCP
|
||||
```
|
||||
|
||||
| Komponente | Port | Ausführung | Aufgabe |
|
||||
|---|---:|---|---|
|
||||
| AI Profile Router | 8081 | systemd, gehärtet | zentrale Client-API und Orchestrierung |
|
||||
| llama.cpp | 8080 | systemd | Textmodell, Tool Calling und MCP |
|
||||
| Whisper | 8084, nur localhost | systemd | Speech-to-Text |
|
||||
| XTTS | 8085, nur localhost | systemd | Text-to-Speech |
|
||||
| TinySearch | 8000, nur localhost | Docker | kompakte Websuche |
|
||||
| SearXNG | intern | Docker | Suchmaschinen-Metasuche |
|
||||
| LLama-GUI | 5240, optional | systemd | manuelle Administration |
|
||||
| Glances | lokal, optional | systemd | Systemmetriken |
|
||||
## Container und Vertrauensgrenzen
|
||||
|
||||
## Request-Fluss
|
||||
| Komponente | Außen erreichbar | Aufgabe |
|
||||
|---|---|---|
|
||||
| Open WebUI | nur WireGuard, Port 8080 | Chat-Oberfläche |
|
||||
| Profile Router | nur WireGuard, Port 8081 | OpenAI-API und Profilwahl |
|
||||
| Profile Controller | nein | startet ausschließlich vier bekannte Profile |
|
||||
| llama.cpp Profile | nein | Inferenz, Tool Calling, integrierte Vision |
|
||||
| SearXNG | nein | Websuche für den lokalen Web-MCP |
|
||||
|
||||
1. Ein Client verwendet ausschließlich Port 8081.
|
||||
2. Der Router veröffentlicht `qwen-fast`, `qwen-medium` und `qwen-long`.
|
||||
3. Passt das aktive Profil nicht zum virtuellen Modell, wird llama.cpp kontrolliert
|
||||
mit dem passenden Profil neu gestartet.
|
||||
4. Der Router wartet auf Modellname und erwartete Kontextgröße.
|
||||
5. Erst dann wird die Anfrage an Port 8080 weitergeleitet.
|
||||
|
||||
Profilwahl und Weiterleitung bilden dabei eine atomare Modell-Lease. Ein
|
||||
zweiter Request kann das Profil nicht mehr zwischen Auswahl und Inferenz
|
||||
wechseln. Laufende Requests werden vor einem GPU-Hotswap vollständig beendet;
|
||||
bei Überschreiten des Drain-Timeouts wird der Wechsel abgebrochen, nicht der
|
||||
Chat.
|
||||
Nur der Profile Controller erhält den Docker-Socket. Der Router erhält weder
|
||||
Socket noch Shell-Zugriff und kann dem Controller nur `fast`, `medium`, `long`
|
||||
oder `experimental` übergeben. Die llama-Container laufen ohne UI,
|
||||
Capabilities und Schreibzugriff auf die Modelldateien.
|
||||
|
||||
## Profilprinzip
|
||||
|
||||
Die Profile sind vollständige systemd-Overrides. Ein Profilwechsel kopiert die
|
||||
gewählte Datei atomar auf `override.conf`, lädt systemd neu und startet genau
|
||||
einen llama.cpp-Dienst neu. Es gibt niemals mehrere Textmodelle gleichzeitig.
|
||||
Das unabhängige Register `/etc/mike-ai/router-profiles.json` definiert Kontext
|
||||
und erwarteten Modellalias. Readiness gilt nur, wenn beides exakt passt; eine
|
||||
abweichende oder fehlende Registry verhindert den Start.
|
||||
Alle Profile verwenden dasselbe selbst gebaute llama.cpp-Image. Separate,
|
||||
normalerweise gestoppte Containerdefinitionen halten Parameter wie Kontext,
|
||||
MTP und CPU-Offload reproduzierbar. Ein Wechsel stoppt das alte Profil und
|
||||
startet genau einen bereits angelegten Container. Dadurch lassen sich Profile
|
||||
einzeln verändern oder duplizieren, ohne vier Modelle parallel im VRAM zu
|
||||
halten.
|
||||
|
||||
## GPU-Hotswap
|
||||
| Profil | Ausgangswert | Zweck |
|
||||
|---|---:|---|
|
||||
| fast | 76.800 Kontext, MTP | mindestens ungefähr 80 Token/s anstreben |
|
||||
| medium | 94.208 Kontext | mehr Kontext ohne CPU-FFN-Offload |
|
||||
| long | 131.072 Kontext | maximale Nutzbarkeit, CPU-Offload erlaubt |
|
||||
| experimental | 76.800 Kontext | isolierte Tests ohne Produktion zu ändern |
|
||||
|
||||
Vision und Bildgenerierung teilen sich die RTX mit dem Textmodell. Der Router:
|
||||
Diese Werte sind reproduzierbare Startwerte, keine Garantie. Nach Einbau der
|
||||
RTX 3060 werden sie auf dem Zielhost erneut gemessen. Die zweite Karte wird
|
||||
nicht automatisch in die Produktionsprofile aufgenommen.
|
||||
|
||||
1. sperrt die GPU-Orchestrierung,
|
||||
2. merkt sich das aktive Textprofil,
|
||||
3. stoppt llama.cpp,
|
||||
4. startet vorübergehend Vision oder FLUX,
|
||||
5. beendet den Hilfsprozess vollständig,
|
||||
6. stellt das ursprüngliche Textprofil wieder her,
|
||||
7. prüft Modell und Kontext vor der Freigabe.
|
||||
## Netzwerk
|
||||
|
||||
Der zuletzt stabile Zustand und temporäre Worker-PIDs werden atomar unter
|
||||
`/var/lib/mike-ai-profile-router/state.json` festgehalten. Beim Routerstart
|
||||
werden ausschließlich dort erfasste Prozesse nach zusätzlicher
|
||||
Kommandozeilenprüfung beendet und das letzte Profil wiederhergestellt.
|
||||
- Docker-Netze liegen ausschließlich unter `172.30.0.0/16`.
|
||||
- Open WebUI und Router binden an `AI_BIND_ADDRESS`, die WireGuard-IP.
|
||||
- Quellrouting schickt KI-Container in Tabelle 51820 über WireGuard.
|
||||
- Eine Blackhole-Default-Route bleibt als Fail-Closed-Fallback bestehen.
|
||||
- Firewallregeln gestatten aus dem VPN nur die beiden veröffentlichten Ports.
|
||||
- Der Host routet weder Universitätsverkehr ins Heimnetz noch Heimverkehr ins
|
||||
Universitätsnetz.
|
||||
- Das Heimnetz muss die Rückroute zur WireGuard-Adresse kennen. Soll auch der
|
||||
Internetzugang der KI über zuhause laufen, braucht der Heim-Peer zusätzlich
|
||||
IP-Forwarding und NAT ins Heim-WAN.
|
||||
|
||||
## Vertrauensgrenzen
|
||||
## Nicht automatisch installiert
|
||||
|
||||
- Port 8081 verlangt einen eigenen API-Key; nur `/health` und `/ready` sind
|
||||
absichtlich anonym und enthalten keine privaten Daten.
|
||||
- Der Client-Key wird vor dem lokalen Upstream entfernt.
|
||||
- Bild-Uploads sind begrenzt. Remote-Bild-URLs sind standardmäßig aus, damit
|
||||
der Vision-Pfad nicht als Zugriff auf Intranet oder Metadatenendpunkte dient.
|
||||
- Maximal 16 Requests werden gleichzeitig bearbeitet; weitere erhalten 429.
|
||||
- Der Dienst benötigt derzeit wegen systemd-Profilwechsel und GPU-Hotswap noch
|
||||
Root-Rechte. Die systemd-Sandbox begrenzt diese, ersetzt aber keine künftige
|
||||
Aufteilung in unprivilegierten Proxy und eng begrenzten Root-Helper.
|
||||
|
||||
## Verzeichnislayout auf dem Zielhost
|
||||
|
||||
```text
|
||||
/opt/mike-ai/
|
||||
ai-profile-router/ Routercode und eigenes Venv
|
||||
llama.cpp/ exakt ein produktiver Build
|
||||
models/ Modelle nach Manifest
|
||||
whisper.cpp/ Speech-to-Text Runtime
|
||||
xtts/ XTTS Runtime und Cache
|
||||
web-search/ Docker Compose für TinySearch/SearXNG
|
||||
|
||||
/etc/mike-ai/
|
||||
mcp-servers.json lokale, geheime produktive Konfiguration
|
||||
*.env Credentials, niemals im Git
|
||||
|
||||
/etc/systemd/system/
|
||||
mike-ai-*.service
|
||||
mike-ai-llama-ui.service.d/
|
||||
override.conf
|
||||
profile-fast.conf.disabled
|
||||
profile-medium.conf.disabled
|
||||
profile-long.conf.disabled
|
||||
```
|
||||
|
||||
## Nicht Teil der Zielarchitektur
|
||||
|
||||
- parallele llama.cpp-Builds
|
||||
- allgemeiner Shell-MCP im Standardprofil
|
||||
- doppelte Unraid-MCPs
|
||||
- RX-spezifische Dienste ohne eingebaute RX
|
||||
- automatisch startende Benchmark-Dienste
|
||||
- Modellkopien außerhalb des dokumentierten Modellverzeichnisses
|
||||
Bildgenerierung, Whisper, TTS sowie Home-Assistant-, ARR- und Unraid-MCPs sind
|
||||
Erweiterungen. Sie benötigen eigene Modelle, Rechte oder Secrets und bleiben
|
||||
im sauberen Basissystem deaktiviert. Multimodale Bildanalyse erfolgt direkt
|
||||
über Qwen plus Projektor. Nicht installierte Worker-Endpunkte antworten klar
|
||||
mit `feature_disabled`, statt alte systemd-Pfade aufzurufen.
|
||||
|
||||
@@ -36,6 +36,10 @@ Zielplattform.
|
||||
### Aktives Fast-Profil
|
||||
|
||||
- Qwen3.8-27B IQ4-MIX
|
||||
- Dateigröße: 14.111.614.400 Bytes
|
||||
- SHA256: `54879ae8738d5938f46cb3b8cbf16bf42b8c85b7d68d7c73f062b612ec183e36`
|
||||
- Öffentliche Herkunft ist noch nicht ausreichend dokumentiert; für eine
|
||||
bitgenaue Migration muss die geprüfte Datei vom Referenzhost gesichert werden.
|
||||
- Kontext 76.800
|
||||
- vollständig auf CUDA0
|
||||
- Flash Attention
|
||||
|
||||
+63
-76
@@ -1,97 +1,84 @@
|
||||
# Saubere Installation
|
||||
# Installation auf einem frischen Debian-Host
|
||||
|
||||
Diese Anleitung beschreibt den Neuaufbau. Sie löscht oder migriert keine Daten
|
||||
automatisch.
|
||||
Der automatisierte Weg ist `install.sh`. Das Skript ist für **Debian 12/13
|
||||
amd64** gedacht, installiert Docker CE, NVIDIA-Treiber/Container-Toolkit,
|
||||
WireGuard, baut llama.cpp reproduzierbar, lädt Modelle mit SHA256-Prüfung und
|
||||
startet den Stack.
|
||||
|
||||
## 1. Voraussetzungen
|
||||
## Vorher klären
|
||||
|
||||
- Debian 13 oder kompatibles Linux
|
||||
- NVIDIA-Treiber und funktionierendes `nvidia-smi`
|
||||
- Build-Werkzeuge, CMake, Git und CUDA Toolkit
|
||||
- Python 3.13 für Router/FLUX und Python 3.11 für XTTS
|
||||
- Docker plus Compose für Websuche
|
||||
- ausreichend freier Speicher; mindestens 15 Prozent auf `/`
|
||||
1. Die Universität muss den ausgehenden WireGuard-Tunnel erlauben.
|
||||
2. Heimnetz, Universitätsnetz und Docker-Netz dürfen sich nicht überschneiden.
|
||||
3. Der WireGuard-Heim-Peer braucht eine feste öffentliche Adresse oder DNS.
|
||||
4. Für KI-Internetzugang über zuhause: Forwarding und NAT am Heim-Peer.
|
||||
5. Das private Repository muss auf dem neuen Host lesbar sein.
|
||||
|
||||
## 2. Benutzer und Verzeichnisse
|
||||
## Debian installieren
|
||||
|
||||
Für produktive Dienste sollen eigene Systembenutzer verwendet werden. Der
|
||||
Router benötigt kontrollierte Berechtigung zum Neustart des llama.cpp-Dienstes;
|
||||
er sollte nicht dauerhaft als root laufen.
|
||||
- Debian 13 minimal, amd64, OpenSSH-Server, kein Desktop erforderlich.
|
||||
- Einen normalen Administrationsbenutzer mit sudo anlegen.
|
||||
- Optional bei physischem Fremdzugriff: LUKS-Verschlüsselung.
|
||||
- BIOS: Above 4G Decoding aktiv; beide GPUs sichtbar machen.
|
||||
|
||||
```text
|
||||
/opt/mike-ai/models
|
||||
/opt/mike-ai/ai-profile-router
|
||||
/etc/mike-ai
|
||||
## Konfiguration
|
||||
|
||||
```bash
|
||||
git clone <PRIVATE-REPOSITORY-URL> AI-Profile-Router
|
||||
cd AI-Profile-Router
|
||||
cp config/install.env.example config/install.env
|
||||
chmod 600 config/install.env
|
||||
editor config/install.env
|
||||
```
|
||||
|
||||
Geheimnisse werden mit Modus `0600` unter `/etc/mike-ai` abgelegt.
|
||||
Mindestens `ADMIN_USER`, `AI_BIND_ADDRESS`, `WG_ADDRESS`,
|
||||
`WG_PEER_PUBLIC_KEY`, `WG_ENDPOINT` und `WG_HOME_SUBNET` anpassen. Private
|
||||
WireGuard-, Router- und WebUI-Schlüssel werden lokal erzeugt und nur unter
|
||||
`/etc/mike-ai` gespeichert.
|
||||
|
||||
## 3. llama.cpp bauen
|
||||
## Installation starten
|
||||
|
||||
`platform/llama/build-llama-cpp.sh` checkt exakt den in
|
||||
`platform/llama/LLAMA_CPP_COMMIT` hinterlegten Commit aus. Vor einem Upgrade:
|
||||
|
||||
1. neuen Commit in einem separaten Build testen,
|
||||
2. Standardbenchmark ausführen,
|
||||
3. MCP-Grammatik und Tool Calls prüfen,
|
||||
4. Commitdatei erst danach aktualisieren.
|
||||
|
||||
## 4. Modelle bereitstellen
|
||||
|
||||
Modelldateien werden nicht in Git gespeichert. Die erwarteten Rollen und
|
||||
Zielpfade stehen in `platform/models/manifest.example.yaml`. Für die lokale
|
||||
Installation wird daraus eine nicht eingecheckte `manifest.local.yaml` mit
|
||||
SHA256-Prüfsummen erstellt.
|
||||
|
||||
## 5. llama.cpp-Dienst und Profile
|
||||
|
||||
Die Dateien aus `platform/systemd` und `platform/profiles` installieren. Danach:
|
||||
|
||||
```text
|
||||
llama-profile fast
|
||||
```bash
|
||||
sudo ./install.sh --config config/install.env
|
||||
```
|
||||
|
||||
Der Befehl muss Port 8080 erst freigeben, wenn Modell und Kontext korrekt sind.
|
||||
Wenn erstmals ein NVIDIA-Treiber installiert wurde, endet das Skript bewusst
|
||||
mit Code 20. Dann neu starten und denselben Befehl erneut ausführen. Das Skript
|
||||
ist auf Wiederholung ausgelegt und löscht keine vorhandenen Modelldateien.
|
||||
|
||||
## 6. MCP-Konfiguration
|
||||
Der Installer zeigt nur den öffentlichen WireGuard-Schlüssel. Diesen am
|
||||
Heim-Peer eintragen:
|
||||
|
||||
`platform/mcp/mcp-servers.example.json` nach `/etc/mike-ai/mcp-servers.json`
|
||||
kopieren und nur benötigte Server aktivieren. Zugangsdaten werden ausschließlich
|
||||
über lokale Environment-Dateien oder einen Secret Broker referenziert.
|
||||
```ini
|
||||
[Peer]
|
||||
PublicKey = <AUSGABE-DES-INSTALLERS>
|
||||
AllowedIPs = 10.77.0.2/32
|
||||
```
|
||||
|
||||
## 7. Router installieren
|
||||
Erst wenn der Tunnel steht, kann der Bootstrap fortfahren. API-Schlüssel
|
||||
werden nicht ausgegeben. Sie liegen root-only unter `/etc/mike-ai`.
|
||||
|
||||
Das bestehende `deploy/install.sh` installiert Router, Vision/Bild-Worker,
|
||||
Whisper und XTTS. Vor produktiver Verwendung müssen Modellpfade in der
|
||||
systemd-Datei gegen das lokale Manifest geprüft werden.
|
||||
## Ergebnis und Abnahme
|
||||
|
||||
Der Installer erzeugt `/etc/mike-ai/router-api-key` (0600), installiert das
|
||||
Profilregister und legt die atomare Zustandsablage an. Danach wird der Key
|
||||
einmal manuell in die Secret-Stores der erlaubten Clients übernommen. Er darf
|
||||
nicht im Terminal-Log, in Screenshots oder in Git dokumentiert werden.
|
||||
- Open WebUI: `http://<WIREGUARD-IP>:8080`
|
||||
- Router: `http://<WIREGUARD-IP>:8081`
|
||||
- llama.cpp-WebUI: absichtlich deaktiviert und nicht veröffentlicht
|
||||
|
||||
Der Router läuft aktuell als gehärteter Root-Dienst, weil er den
|
||||
llama.cpp-Systemdienst und temporäre GPU-Worker kontrolliert. Das ist eine
|
||||
bewusste Restabweichung. Ein späterer V3-Schritt soll den HTTP-Proxy als eigenen
|
||||
Benutzer ausführen und nur Profil-/Hotswap-Befehle an einen fest
|
||||
parametrisierten Root-Helper delegieren.
|
||||
```bash
|
||||
sudo systemctl status wg-quick@wg0 mike-ai-network-guard
|
||||
sudo docker compose --env-file /etc/mike-ai/stack.env \
|
||||
-f /opt/mike-ai/stack/compose.yaml ps
|
||||
curl http://<WIREGUARD-IP>:8081/health
|
||||
```
|
||||
|
||||
## 8. Websuche
|
||||
Zusätzlich prüfen: Uni-LAN sieht keine KI-Ports; Heimnetz erreicht beide;
|
||||
gestopptes WireGuard lässt KI-Container nicht ins Internet; jeder Profilwechsel
|
||||
startet exakt einen llama-Container; Text, Tool Call und Bild funktionieren.
|
||||
|
||||
TinySearch und SearXNG bleiben als einziges Docker-Teilsystem isoliert. Die
|
||||
Suchdienste sollen nur an localhost gebunden werden; nur der Web-MCP greift
|
||||
darauf zu.
|
||||
Die öffentliche Standardkonfiguration nutzt `UD-IQ4_XS`. Das bislang schnellste
|
||||
Referenzprofil nutzt dagegen die lokal vorhandene `IQ4-MIX`-Datei. Für eine
|
||||
bitgenaue Migration diese Datei anhand der in `CURRENT_REFERENCE.md`
|
||||
dokumentierten SHA256 in das Modellverzeichnis kopieren und die drei
|
||||
`*_MODEL_FILE`-Werte anpassen. Der Installer löscht vorhandene Modelle nicht.
|
||||
|
||||
## 9. Verifikation
|
||||
|
||||
`platform/checks/verify-platform.sh` kontrolliert:
|
||||
|
||||
- freien Plattenplatz,
|
||||
- GPU und VRAM,
|
||||
- aktive Dienste,
|
||||
- Ports,
|
||||
- Routermodelle und aktives Profil,
|
||||
- llama.cpp-Health,
|
||||
- unerwartete RX- und Benchmark-Dienste.
|
||||
|
||||
Erst nach erfolgreicher Prüfung werden Clients auf Port 8081 umgestellt.
|
||||
Open-WebUI-Daten liegen in einem Docker-Volume und müssen separat gesichert
|
||||
werden. Geheimnisse und Chatdaten gehören nie in Git.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Roadmap: sauberer KI-Host
|
||||
|
||||
## Phase 0 – Entscheidungen und Freigaben
|
||||
|
||||
- VPN-Nutzung mit der Universität abstimmen.
|
||||
- Eindeutige Netze und Heim-WireGuard-Peer festlegen.
|
||||
- Festplattenverschlüsselung und Remote-Unlock entscheiden.
|
||||
- Repository- und Secret-Backup prüfen.
|
||||
|
||||
## Phase 1 – Grundsystem
|
||||
|
||||
- Debian 13 minimal und OpenSSH installieren.
|
||||
- Firmware/BIOS und beide NVIDIA-Karten prüfen.
|
||||
- Updates, Zeitsynchronisation und administrativen Zugang testen.
|
||||
|
||||
## Phase 2 – automatischer Bootstrap
|
||||
|
||||
- `config/install.env` ausfüllen.
|
||||
- `install.sh` ausführen, bei Treiberinstallation neu starten und wiederholen.
|
||||
- WireGuard-Peer zuhause ergänzen.
|
||||
- Docker-, GPU- und Fail-Closed-Netztest bestehen.
|
||||
|
||||
## Phase 3 – Inferenz abnehmen
|
||||
|
||||
- Fast/Medium/Long mit derselben Testserie messen.
|
||||
- Kontext, Prompt-Speed, Ausgabe-Speed und VRAM dokumentieren.
|
||||
- RTX 3060 zuerst nur im Experimentalprofil testen.
|
||||
- Erst nach Qualitäts- und Geschwindigkeitsvergleich Produktionswerte ändern.
|
||||
|
||||
## Phase 4 – optionale Fähigkeiten
|
||||
|
||||
- Home-Assistant-MCP mit kleinsten Rechten.
|
||||
- ARR-MCP zunächst read-only, Schreibaktionen mit Preview/Approval.
|
||||
- Unraid-/Docker-Zugriff über begrenzte Broker statt Shell-MCP.
|
||||
- Whisper, TTS oder Bildgenerierung jeweils als eigener Container.
|
||||
|
||||
## Phase 5 – Betrieb
|
||||
|
||||
- Open-WebUI-Volume, Konfigurationen und Secrets verschlüsselt sichern.
|
||||
- Image- und llama.cpp-Upgrades im Experimentalprofil testen.
|
||||
- Logs ohne Prompts/Secrets, Metriken für GPU, RAM und Tokenraten.
|
||||
- Recovery auf leerem Testsystem regelmäßig proben.
|
||||
|
||||
Fertig ist der Host erst, wenn er sich aus Repository und Secret-Backup neu
|
||||
erzeugen lässt, das Uni-Netz keine KI-Ports sieht, ein Tunnelverlust
|
||||
fail-closed ist und alle drei Profile den Standardbenchmark bestehen.
|
||||
@@ -55,16 +55,15 @@ Jede Komponente bekommt zusätzlich:
|
||||
- benötigte Environment-Namen ohne Werte
|
||||
- Liste read-only und schreibender Werkzeuge
|
||||
|
||||
## 3. Basissystem-Bootstrap – offen
|
||||
## 3. Basissystem-Bootstrap – umgesetzt, Praxistest offen
|
||||
|
||||
Ein idempotentes Bootstrap-Skript muss noch erstellen:
|
||||
`install.sh` erstellt inzwischen:
|
||||
|
||||
- Paketquellen und benötigte Debian-Pakete
|
||||
- NVIDIA-Treiber und exakte Version
|
||||
- CUDA Toolkit und Buildabhängigkeiten
|
||||
- Docker und Compose
|
||||
- Python 3.13 und Python 3.11/uv
|
||||
- Dienstbenutzer und Gruppen
|
||||
- die benötigten Container-Runtimes und Dienstbenutzer in Images
|
||||
- Verzeichnisse, Eigentümer und Dateirechte
|
||||
- Firewallregeln
|
||||
- Journalgrößenlimit
|
||||
@@ -91,7 +90,7 @@ Noch festzulegen:
|
||||
- Restore ohne Ausgabe der Werte in Terminal- oder Modellkontext
|
||||
- Funktionstest mit ausschließlich Statuscode, niemals Tokenanzeige
|
||||
|
||||
## 5. Netzwerk und DNS – offen
|
||||
## 5. Netzwerk und DNS – Vorlage umgesetzt, Standortwerte offen
|
||||
|
||||
Dokumentiert werden müssen:
|
||||
|
||||
@@ -157,9 +156,9 @@ Festlegen, welche Daten persistent sein sollen:
|
||||
- Benchmarkresultate: eigenes Repository
|
||||
- Logs: ohne Prompt- und Tool-Antwortinhalte
|
||||
|
||||
## 9. Ende-zu-Ende-Installer – offen
|
||||
## 9. Ende-zu-Ende-Installer – implementiert, Hardware-Abnahme offen
|
||||
|
||||
Der gewünschte Endzustand ist:
|
||||
Der Ablauf ist jetzt in `install.sh` zusammengeführt:
|
||||
|
||||
```text
|
||||
bootstrap-host
|
||||
|
||||
+50
-60
@@ -1,73 +1,63 @@
|
||||
# Sicherheitsmodell
|
||||
|
||||
## Grundsatz
|
||||
## Netzgrenze
|
||||
|
||||
Das lokale Modell erhält nur die Werkzeuge, die es für den aktuellen Modus
|
||||
benötigt. Lokalität allein ersetzt keine Zugriffskontrolle.
|
||||
- KI-Ports binden ausschließlich an die WireGuard-IP.
|
||||
- Docker-Netze `172.30.0.0/16` verwenden eine eigene Routingtabelle.
|
||||
- Heimnetz- und optionaler Internetverkehr laufen über WireGuard.
|
||||
- Eine Blackhole-Default-Route verhindert Fail-open bei Tunnelverlust.
|
||||
- `DOCKER-USER` erlaubt nur etablierte Verbindungen, KI→WireGuard und
|
||||
WireGuard→Open-WebUI/Router.
|
||||
- Der Host ist kein Router zwischen Universitäts- und Heimnetz.
|
||||
|
||||
## MCP-Profile
|
||||
Docker-publizierte Ports können gewöhnliche Host-Firewallregeln umgehen.
|
||||
Darum setzt der Installer seine Regeln ausdrücklich in `DOCKER-USER` und
|
||||
verlässt sich nicht allein auf UFW.
|
||||
|
||||
Empfohlene Trennung:
|
||||
## Containergrenzen
|
||||
|
||||
| Modus | Werkzeuge |
|
||||
- llama.cpp: read-only, keine Capabilities, Modelle read-only, keine Ports.
|
||||
- Router: unprivilegierter Benutzer, kein Docker-Socket, feste API-Oberfläche.
|
||||
- Profile Controller: einzige Socket-Ausnahme; feste Profile und nur
|
||||
List/Start/Stop, keine frei wählbaren Images, Befehle oder Mounts.
|
||||
- Open WebUI: einziges persistentes Chat-Volume.
|
||||
- SearXNG: intern, Suchanfragen ohne Chatverlauf.
|
||||
|
||||
Ein Docker-Socket bleibt grundsätzlich privilegiert. Der Controller reduziert
|
||||
die erreichbare Funktion stark, ersetzt aber keine zusätzliche Socket-Proxy-
|
||||
Sandbox. Er ist klein, testbar und nicht von Clients direkt erreichbar.
|
||||
|
||||
## Secrets und private Daten
|
||||
|
||||
- Keine Secrets in Git, Prompts, MCP-Schemas, Logs oder Screenshots.
|
||||
- Installer-Konfiguration und `/etc/mike-ai/*` haben restriktive Rechte.
|
||||
- Router-, Controller- und WebUI-Schlüssel sind getrennt und zufällig.
|
||||
- Das Modell bekommt keine Schlüsselwerte zurück; spätere Integrationen nutzen
|
||||
lokale Broker/Environment-Dateien.
|
||||
- Open-WebUI-Volume kann Chats enthalten und wird nur verschlüsselt gesichert.
|
||||
|
||||
## Werkzeugprofile
|
||||
|
||||
| Modus | Erlaubte Werkzeuge |
|
||||
|---|---|
|
||||
| Standard | Websuche, harmlose lokale Hilfsfunktionen |
|
||||
| Home Assistant | HA-Administration plus Websuche |
|
||||
| ARR | Sonarr/Radarr plus Websuche |
|
||||
| Unraid Read-only | Diagnose, Logs, Status |
|
||||
| Unraid Write | nur bewusst aktiviert, mit Vorschau und Approval Ticket |
|
||||
| Standard | lokale Websuche, harmlose Hilfsfunktionen |
|
||||
| Home Assistant | eigener begrenzter HA-MCP |
|
||||
| ARR | Sonarr/Radarr, zuerst read-only |
|
||||
| Unraid Diagnose | Status und eng begrenzte Logs |
|
||||
| Administration | Vorschau, Approval-Ticket, Verifikation |
|
||||
|
||||
## Nicht im Standardprofil
|
||||
|
||||
- allgemeine Shell
|
||||
- `python3`, `ssh`, `scp` oder beliebiges `curl`
|
||||
- Container erstellen, verändern oder löschen
|
||||
- Registry-/Storage-Direktzugriff
|
||||
- uneingeschränkte Dateisuche
|
||||
|
||||
## Secrets
|
||||
|
||||
- Keine Secrets in Git, Prompts, MCP-Schemas oder Logs.
|
||||
- Konfiguration referenziert nur Namen lokaler Environment-Dateien.
|
||||
- Dateien mit Secrets: Eigentümer root oder Dienstbenutzer, Modus `0600`.
|
||||
- Tokens werden pro Dienst getrennt und minimal berechtigt.
|
||||
- Ein Secret Broker oder Wrapper stellt Verbindungen her, ohne Tokens an das
|
||||
Modell zurückzugeben.
|
||||
|
||||
## Netzwerk
|
||||
|
||||
- Port 8080 nur localhost oder administratives VLAN.
|
||||
- Clients verwenden Port 8081.
|
||||
- Whisper, XTTS, TinySearch und SearXNG nur localhost.
|
||||
- Firewall erlaubt nur bekannte Quellnetze.
|
||||
- Externe Suche erhält nur die tatsächliche Suchanfrage, keine Chat-Historie.
|
||||
|
||||
## Router-Grenze
|
||||
|
||||
- Alle fachlichen Endpunkte verlangen einen mindestens 32 Zeichen langen,
|
||||
zufälligen Router-Key. Der Dienst startet ohne gültigen Key nicht.
|
||||
- `/health` und `/ready` sind die einzigen anonymen Endpunkte und geben nur
|
||||
groben Betriebszustand aus.
|
||||
- Authentifizierungsheader werden niemals an llama.cpp weitergereicht.
|
||||
- Remote-Bild-URLs sind standardmäßig gesperrt. Data-URLs werden auf MIME-Typ,
|
||||
Base64-Gültigkeit und 20 MiB Maximalgröße geprüft.
|
||||
- Die Zahl gleichzeitiger Requests ist begrenzt; große Uploads sind global
|
||||
begrenzt und generierte Bilder werden nach Alter, Anzahl und Größe bereinigt.
|
||||
- Crash-Recovery beendet keine PID nur aufgrund einer Zahl, sondern verlangt
|
||||
zusätzlich einen erwarteten Prozessmarker in `/proc/<pid>/cmdline`.
|
||||
Allgemeine Shell, beliebiges SSH/SCP, freies `curl`, Docker-Administration und
|
||||
Dateisystemsuche gehören nicht ins Standardprofil.
|
||||
|
||||
## Schreibaktionen
|
||||
|
||||
Jede destruktive oder persistente Aktion verwendet:
|
||||
Persistente oder destruktive Änderungen folgen immer: Bestandsaufnahme,
|
||||
exakte Vorschau, an die Vorschau gebundene Freigabe, unveränderte Ausführung,
|
||||
anschließende Verifikation.
|
||||
|
||||
1. read-only Bestandsaufnahme,
|
||||
2. exakte Vorschau,
|
||||
3. an diese Vorschau gebundenes Approval Ticket,
|
||||
4. unveränderte Ausführung,
|
||||
5. anschließende Verifikation.
|
||||
## Vor jedem Push
|
||||
|
||||
## Repository-Prüfung vor jedem Push
|
||||
|
||||
- Suche nach Token-, Passwort- und Private-Key-Mustern.
|
||||
- Keine `.env`, Zertifikate, Logs, Bilder, Audio oder Modellartefakte.
|
||||
- Keine echten internen API-Schlüssel in Beispielen.
|
||||
- Private-Key-, Token-, Passwort- und API-Key-Muster suchen.
|
||||
- Keine `.env`, Zertifikate, Logs, Bilder, Audio oder Modelle einchecken.
|
||||
- Beispiele enthalten nur Platzhalter; interne Hostnamen nur wenn bewusst.
|
||||
- Änderungen am Controller und Netzwerkguard mit Tests und Review versehen.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# WireGuard-Heimseite
|
||||
|
||||
Der Installer kann nur den KI-Host konfigurieren. Einmalig muss der vorhandene
|
||||
WireGuard-Router im Heimnetz den neuen Peer kennen. Ohne diesen externen Schritt
|
||||
kann kein automatisches Skript auf dem Uni-Host den Tunnel fertigstellen.
|
||||
|
||||
## Peer ergänzen
|
||||
|
||||
Beispiel mit KI-WireGuard-Adresse `10.77.0.2/32`:
|
||||
|
||||
```ini
|
||||
[Peer]
|
||||
PublicKey = <PUBLIC-KEY-DES-KI-HOSTS>
|
||||
AllowedIPs = 10.77.0.2/32
|
||||
```
|
||||
|
||||
Das Heimgerät muss das LAN `192.168.1.0/24` zum Tunnel routen können. Geräte im
|
||||
Heimnetz benötigen entweder eine Route für `10.77.0.2/32` über den
|
||||
WireGuard-Router oder der Router maskiert den VPN-Verkehr passend.
|
||||
|
||||
## KI-Internetzugang über zuhause
|
||||
|
||||
Wenn `WG_ROUTE_AI_INTERNET=true` gesetzt ist, muss der Heim-Peer IPv4-Forwarding
|
||||
und NAT ins WAN erlauben. Das wird auf dem Heimrouter eingerichtet, nicht auf
|
||||
dem Universitätsnetz. Beispielprinzip für nftables:
|
||||
|
||||
```nft
|
||||
table inet mike_ai {
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy accept;
|
||||
iifname "wg0" ip saddr 10.77.0.2 accept
|
||||
oifname "wg0" ip daddr 10.77.0.2 ct state established,related accept
|
||||
}
|
||||
}
|
||||
|
||||
table ip mike_ai_nat {
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority 100; policy accept;
|
||||
ip saddr 10.77.0.2 oifname "<HEIM-WAN-INTERFACE>" masquerade
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Die tatsächlichen Interface-Namen und die bestehende Firewall des Heimrouters
|
||||
gehen vor. Regeln nicht blind neben eine bereits verwaltete Firewall kopieren.
|
||||
|
||||
## Sicherheitsprüfung
|
||||
|
||||
1. Vom Heimnetz `10.77.0.2` erreichen.
|
||||
2. Open WebUI auf `10.77.0.2:8080` erreichen.
|
||||
3. Aus dem Universitäts-LAN Port 8080/8081 nicht erreichen.
|
||||
4. `wg0` am KI-Host stoppen: KI-Container dürfen nun weder Heimnetz noch
|
||||
Internet erreichen.
|
||||
5. Der Debian-Host selbst darf weiterhin nur die ausdrücklich gewünschte
|
||||
Administration über das Uni-LAN anbieten.
|
||||
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproducible bootstrap for a fresh Debian 12/13 AI host.
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG=""
|
||||
STACK_DIR=/opt/mike-ai/stack
|
||||
SECRETS_DIR=/etc/mike-ai
|
||||
STATE_DIR=/srv/mike-ai
|
||||
|
||||
log() { printf '\n==> %s\n' "$*"; }
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Aufruf: sudo ./install.sh --config /root/mike-ai-install.env
|
||||
|
||||
Das Skript ist idempotent und darf nach einem Treiber-Reboot erneut ausgeführt
|
||||
werden. Es formatiert keine Datenträger und löscht keine Modelle oder Volumes.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--config) CONFIG="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "Unbekanntes Argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
[[ -n "$CONFIG" && -f "$CONFIG" ]] || die "Konfigurationsdatei fehlt (--config)."
|
||||
config_mode=$(stat -c '%a' "$CONFIG")
|
||||
if (( (8#$config_mode & 077) != 0 )); then
|
||||
die "Konfigurationsdatei darf nicht für Gruppe/Andere schreib- oder lesbar sein (chmod 600)."
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONFIG"
|
||||
|
||||
required=(AI_HOSTNAME ADMIN_USER AI_BIND_ADDRESS MODEL_DIR FAST_MODEL_FILE
|
||||
FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL
|
||||
MEDIUM_MODEL_SHA256 LONG_MODEL_FILE LONG_MODEL_URL LONG_MODEL_SHA256
|
||||
EXPERIMENTAL_MODEL_FILE EXPERIMENTAL_MODEL_URL EXPERIMENTAL_MODEL_SHA256
|
||||
VISION_PROJECTOR_FILE VISION_PROJECTOR_URL VISION_PROJECTOR_SHA256
|
||||
MTP_MODEL_FILE MTP_MODEL_URL MTP_MODEL_SHA256)
|
||||
for name in "${required[@]}"; do
|
||||
[[ -n "${!name:-}" ]] || die "Pflichtwert $name fehlt."
|
||||
done
|
||||
|
||||
source /etc/os-release
|
||||
[[ ${ID:-} == debian ]] || die "Unterstützt wird Debian, gefunden: ${ID:-unbekannt}."
|
||||
[[ ${VERSION_ID%%.*} == 12 || ${VERSION_ID%%.*} == 13 ]] || \
|
||||
die "Unterstützt werden Debian 12 und 13."
|
||||
[[ $(dpkg --print-architecture) == amd64 ]] || die "Dieser Stack erwartet amd64."
|
||||
id "$ADMIN_USER" >/dev/null 2>&1 || die "ADMIN_USER $ADMIN_USER existiert nicht."
|
||||
|
||||
install_base_packages() {
|
||||
log "Basispakete installieren"
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git gnupg jq openssl wireguard-tools iptables \
|
||||
iproute2 pciutils rsync unattended-upgrades
|
||||
}
|
||||
|
||||
install_docker() {
|
||||
log "Docker CE aus dem offiziellen Repository installieren"
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||
-o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
cat >/etc/apt/sources.list.d/docker.sources <<EOF
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/debian
|
||||
Suites: ${VERSION_CODENAME}
|
||||
Components: stable
|
||||
Architectures: $(dpkg --print-architecture)
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
EOF
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
systemctl enable --now docker
|
||||
}
|
||||
|
||||
install_nvidia() {
|
||||
if ! command -v nvidia-smi >/dev/null 2>&1; then
|
||||
[[ ${INSTALL_NVIDIA_DRIVER:-false} == true ]] || \
|
||||
die "NVIDIA-Treiber fehlt. Installiere ihn oder setze INSTALL_NVIDIA_DRIVER=true."
|
||||
log "NVIDIA-Treiber aus Debian installieren"
|
||||
if ! apt-cache show nvidia-driver >/dev/null 2>&1; then
|
||||
die "Paket nvidia-driver fehlt. Debian-Quellen müssen contrib, non-free und non-free-firmware enthalten."
|
||||
fi
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y nvidia-driver firmware-misc-nonfree
|
||||
printf '\nNVIDIA-Treiber installiert. Jetzt neu starten und danach denselben Installer erneut ausführen.\n'
|
||||
exit 20
|
||||
fi
|
||||
nvidia-smi >/dev/null || die "nvidia-smi ist vorhanden, aber der Treiber funktioniert nicht."
|
||||
|
||||
log "NVIDIA Container Toolkit installieren"
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
|
||||
gpg --dearmor --yes -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
|
||||
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
|
||||
>/etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y nvidia-container-toolkit
|
||||
nvidia-ctk runtime configure --runtime=docker
|
||||
systemctl restart docker
|
||||
docker run --rm --gpus all nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi >/dev/null
|
||||
}
|
||||
|
||||
setup_wireguard() {
|
||||
[[ ${WIREGUARD_ENABLE:-false} == true ]] || return 0
|
||||
for name in WG_INTERFACE WG_ADDRESS WG_HOME_SUBNET WG_PEER_PUBLIC_KEY WG_PEER_ENDPOINT; do
|
||||
[[ -n "${!name:-}" && ${!name} != REPLACE_* ]] || die "WireGuard-Wert $name fehlt."
|
||||
done
|
||||
log "WireGuard als ausgehenden Heimnetz-Tunnel konfigurieren"
|
||||
install -d -m 0700 /etc/wireguard
|
||||
local key_file="/etc/wireguard/${WG_INTERFACE}.key"
|
||||
[[ -s $key_file ]] || wg genkey >"$key_file"
|
||||
chmod 0600 "$key_file"
|
||||
local private_key
|
||||
private_key=$(<"$key_file")
|
||||
local allowed_ips="$WG_HOME_SUBNET"
|
||||
[[ ${WG_ROUTE_AI_INTERNET:-true} == true ]] && allowed_ips="0.0.0.0/0, ::/0"
|
||||
{
|
||||
printf '[Interface]\nAddress = %s\nPrivateKey = %s\nTable = off\n' "$WG_ADDRESS" "$private_key"
|
||||
printf '\n[Peer]\nPublicKey = %s\nEndpoint = %s\nAllowedIPs = %s\nPersistentKeepalive = 25\n' \
|
||||
"$WG_PEER_PUBLIC_KEY" "$WG_PEER_ENDPOINT" "$allowed_ips"
|
||||
if [[ -n ${WG_PEER_PRESHARED_KEY_FILE:-} ]]; then
|
||||
[[ -s $WG_PEER_PRESHARED_KEY_FILE ]] || die "WireGuard-Preshared-Key-Datei fehlt."
|
||||
printf 'PresharedKey = %s\n' "$(<"$WG_PEER_PRESHARED_KEY_FILE")"
|
||||
fi
|
||||
} >"/etc/wireguard/${WG_INTERFACE}.conf"
|
||||
chmod 0600 "/etc/wireguard/${WG_INTERFACE}.conf"
|
||||
systemctl enable --now "wg-quick@${WG_INTERFACE}"
|
||||
ip address show "$WG_INTERFACE" >/dev/null
|
||||
local wg_host=${WG_ADDRESS%/*}
|
||||
[[ $AI_BIND_ADDRESS == "$wg_host" ]] || \
|
||||
die "AI_BIND_ADDRESS muss der WireGuard-Adresse ohne Präfix entsprechen ($wg_host)."
|
||||
printf 'WireGuard-Public-Key des KI-Hosts: %s\n' "$(wg pubkey <"$key_file")"
|
||||
}
|
||||
|
||||
install_stack_files() {
|
||||
log "Stackdateien installieren"
|
||||
install -d -m 0755 "$STACK_DIR" "$MODEL_DIR" "$STATE_DIR/backups"
|
||||
rsync -a --delete --exclude .git --exclude '*.local.*' \
|
||||
--exclude config/install.env "$ROOT_DIR/" "$STACK_DIR/"
|
||||
install -d -m 0700 "$SECRETS_DIR"
|
||||
[[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key
|
||||
[[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token
|
||||
[[ -s $SECRETS_DIR/webui-secret ]] || openssl rand -base64 48 >$SECRETS_DIR/webui-secret
|
||||
chmod 0600 "$SECRETS_DIR"/*
|
||||
|
||||
local searx="$STACK_DIR/platform/web-search/searxng-settings.yml"
|
||||
if [[ ! -s $searx ]]; then
|
||||
cp "$STACK_DIR/platform/web-search/searxng-settings.example.yml" "$searx"
|
||||
sed -i "s/CHANGE_ME_GENERATE_RANDOM_SECRET/$(openssl rand -hex 32)/" "$searx"
|
||||
fi
|
||||
# The official image reads this as its unprivileged uid (977).
|
||||
chown root:977 "$searx"
|
||||
chmod 0640 "$searx"
|
||||
|
||||
cat >$SECRETS_DIR/stack.env <<EOF
|
||||
AI_BIND_ADDRESS=$AI_BIND_ADDRESS
|
||||
MODEL_DIR=$MODEL_DIR
|
||||
ROUTER_API_KEY=$(<$SECRETS_DIR/router-api-key)
|
||||
CONTROLLER_TOKEN=$(<$SECRETS_DIR/controller-token)
|
||||
WEBUI_SECRET_KEY=$(<$SECRETS_DIR/webui-secret)
|
||||
OPENWEBUI_IMAGE=${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.9.5}
|
||||
OPENWEBUI_ENABLE_SIGNUP=${OPENWEBUI_ENABLE_SIGNUP:-false}
|
||||
AI_DNS=${WG_DNS:-1.1.1.1}
|
||||
FAST_MODEL_FILE=$FAST_MODEL_FILE
|
||||
MEDIUM_MODEL_FILE=$MEDIUM_MODEL_FILE
|
||||
LONG_MODEL_FILE=$LONG_MODEL_FILE
|
||||
EXPERIMENTAL_MODEL_FILE=$EXPERIMENTAL_MODEL_FILE
|
||||
VISION_PROJECTOR_FILE=$VISION_PROJECTOR_FILE
|
||||
MTP_MODEL_FILE=$MTP_MODEL_FILE
|
||||
FAST_CONTEXT=${FAST_CONTEXT:-76800}
|
||||
MEDIUM_CONTEXT=${MEDIUM_CONTEXT:-94208}
|
||||
LONG_CONTEXT=${LONG_CONTEXT:-131072}
|
||||
EXPERIMENTAL_CONTEXT=${EXPERIMENTAL_CONTEXT:-76800}
|
||||
FAST_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}
|
||||
MEDIUM_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}
|
||||
LONG_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}
|
||||
EXPERIMENTAL_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}
|
||||
LLAMA_THREADS=${LLAMA_THREADS:-6}
|
||||
LLAMA_THREADS_BATCH=${LLAMA_THREADS_BATCH:-6}
|
||||
EOF
|
||||
chmod 0600 $SECRETS_DIR/stack.env
|
||||
}
|
||||
|
||||
download_one() {
|
||||
local relative=$1 url=$2 expected=$3 target="$MODEL_DIR/$1"
|
||||
install -d -m 0755 "$(dirname "$target")"
|
||||
if [[ -f $target ]] && printf '%s %s\n' "$expected" "$target" | sha256sum -c - >/dev/null 2>&1; then
|
||||
printf 'Vorhanden und geprüft: %s\n' "$relative"
|
||||
return
|
||||
fi
|
||||
log "Modell laden: $relative"
|
||||
curl --fail --location --continue-at - --retry 5 --retry-all-errors \
|
||||
--output "$target.partial" "$url"
|
||||
printf '%s %s\n' "$expected" "$target.partial" | sha256sum -c -
|
||||
mv "$target.partial" "$target"
|
||||
}
|
||||
|
||||
download_models() {
|
||||
[[ ${SKIP_MODEL_DOWNLOADS:-false} == true ]] && return 0
|
||||
declare -A seen=()
|
||||
local row relative url hash
|
||||
while IFS='|' read -r relative url hash; do
|
||||
[[ -n ${seen[$relative]:-} ]] && continue
|
||||
seen[$relative]=1
|
||||
download_one "$relative" "$url" "$hash"
|
||||
done <<EOF
|
||||
$FAST_MODEL_FILE|$FAST_MODEL_URL|$FAST_MODEL_SHA256
|
||||
$MEDIUM_MODEL_FILE|$MEDIUM_MODEL_URL|$MEDIUM_MODEL_SHA256
|
||||
$LONG_MODEL_FILE|$LONG_MODEL_URL|$LONG_MODEL_SHA256
|
||||
$EXPERIMENTAL_MODEL_FILE|$EXPERIMENTAL_MODEL_URL|$EXPERIMENTAL_MODEL_SHA256
|
||||
$VISION_PROJECTOR_FILE|$VISION_PROJECTOR_URL|$VISION_PROJECTOR_SHA256
|
||||
$MTP_MODEL_FILE|$MTP_MODEL_URL|$MTP_MODEL_SHA256
|
||||
EOF
|
||||
}
|
||||
|
||||
install_routing_guard() {
|
||||
[[ ${WIREGUARD_ENABLE:-false} == true ]] || return 0
|
||||
log "KI-Container auf WireGuard routen und Uni-Netz als Transit sperren"
|
||||
cat >/usr/local/sbin/mike-ai-network-guard <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
WG=$WG_INTERFACE
|
||||
HOME_NET=$WG_HOME_SUBNET
|
||||
TABLE=51820
|
||||
for NET in 172.30.10.0/24 172.30.30.0/24 172.30.40.0/24; do
|
||||
ip rule add from \"\$NET\" table \"\$TABLE\" priority 12000 2>/dev/null || true
|
||||
done
|
||||
ip route replace \"\$HOME_NET\" dev \"\$WG\"
|
||||
ip route replace \"\$HOME_NET\" dev \"\$WG\" table \"\$TABLE\"
|
||||
ip route replace blackhole default metric 32767 table \"\$TABLE\"
|
||||
EOF
|
||||
if [[ ${WG_ROUTE_AI_INTERNET:-true} == true ]]; then
|
||||
printf 'ip route replace default dev "$WG" metric 10 table "$TABLE"\n' >>/usr/local/sbin/mike-ai-network-guard
|
||||
fi
|
||||
cat >>/usr/local/sbin/mike-ai-network-guard <<'EOF'
|
||||
# Explicit forwarding policy: AI containers may use wg0; WireGuard clients may
|
||||
# reach the two published UI/API ports. Neither side may use this host as a
|
||||
# general university<->home router. The blackhole route above prevents a
|
||||
# fail-open to the university gateway when wg0 loses its peer/default route.
|
||||
add_rule() { iptables -C DOCKER-USER "$@" 2>/dev/null || iptables -I DOCKER-USER 1 "$@"; }
|
||||
add_rule -o "$WG" -j DROP
|
||||
add_rule -i "$WG" -j DROP
|
||||
add_rule -s 172.30.0.0/16 -o "$WG" -j ACCEPT
|
||||
add_rule -i "$WG" -d 172.30.10.0/24 -p tcp -m multiport --dports 8080,8081 -j ACCEPT
|
||||
add_rule -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
EOF
|
||||
chmod 0755 /usr/local/sbin/mike-ai-network-guard
|
||||
cat >/etc/systemd/system/mike-ai-network-guard.service <<EOF
|
||||
[Unit]
|
||||
Description=Routing guard for Mike AI Docker networks
|
||||
After=docker.service wg-quick@${WG_INTERFACE}.service
|
||||
Requires=docker.service wg-quick@${WG_INTERFACE}.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/mike-ai-network-guard
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now mike-ai-network-guard.service
|
||||
}
|
||||
|
||||
build_and_start() {
|
||||
log "llama.cpp und Plattform-Container bauen"
|
||||
local commit
|
||||
commit=$(<"$STACK_DIR/platform/llama/LLAMA_CPP_COMMIT")
|
||||
cd "$STACK_DIR"
|
||||
docker build --build-arg LLAMA_CPP_COMMIT="$commit" \
|
||||
-f platform/docker/llama-cpp/Dockerfile -t mike-ai/llama.cpp:local .
|
||||
docker compose --env-file "$SECRETS_DIR/stack.env" --profile inference create \
|
||||
llama-fast llama-medium llama-long llama-experimental
|
||||
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --build \
|
||||
profile-controller router searxng open-webui
|
||||
|
||||
log "Fast-Profil aktivieren und Readiness prüfen"
|
||||
docker compose --env-file "$SECRETS_DIR/stack.env" exec -T router python - <<'PY'
|
||||
import json, os, time, urllib.request
|
||||
key = os.environ["ROUTER_API_KEY"]
|
||||
request = urllib.request.Request(
|
||||
"http://127.0.0.1:8081/fast", method="POST",
|
||||
headers={"Authorization": f"Bearer {key}"})
|
||||
with urllib.request.urlopen(request, timeout=700) as response:
|
||||
print(json.dumps(json.load(response), indent=2))
|
||||
deadline = time.monotonic() + 60
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen("http://127.0.0.1:8081/ready", timeout=5) as response:
|
||||
if response.status == 200:
|
||||
print("Router und Fast-Profil sind bereit.")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise SystemExit("Readiness-Check fehlgeschlagen")
|
||||
PY
|
||||
}
|
||||
|
||||
hostnamectl set-hostname "$AI_HOSTNAME"
|
||||
install_base_packages
|
||||
install_docker
|
||||
install_nvidia
|
||||
setup_wireguard
|
||||
install_stack_files
|
||||
download_models
|
||||
install_routing_guard
|
||||
build_and_start
|
||||
|
||||
log "Installation abgeschlossen"
|
||||
cat <<EOF
|
||||
OpenWebUI: http://${AI_BIND_ADDRESS}:8080
|
||||
Router-API: http://${AI_BIND_ADDRESS}:8081
|
||||
|
||||
Die geheimen Schlüssel liegen ausschließlich unter $SECRETS_DIR (0600).
|
||||
Konfiguriere auf der Heimseite für den KI-Peer mindestens die Rückroute
|
||||
${WG_ADDRESS:-10.77.0.2/32}; bei Internet-Routing zusätzlich NAT/Forwarding.
|
||||
EOF
|
||||
@@ -0,0 +1,34 @@
|
||||
ARG CUDA_VERSION=12.8.1
|
||||
ARG UBUNTU_VERSION=24.04
|
||||
|
||||
FROM nvidia/cuda:${CUDA_VERSION}-devel-ubuntu${UBUNTU_VERSION} AS build
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG LLAMA_CPP_COMMIT
|
||||
ARG CUDA_ARCHITECTURES="86;120"
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates cmake git libcurl4-openssl-dev ninja-build pkg-config && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN test -n "$LLAMA_CPP_COMMIT"
|
||||
RUN git clone --filter=blob:none https://github.com/ggml-org/llama.cpp /src/llama.cpp && \
|
||||
git -C /src/llama.cpp checkout "$LLAMA_CPP_COMMIT"
|
||||
RUN cmake -S /src/llama.cpp -B /src/llama.cpp/build -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DGGML_CUDA=ON \
|
||||
-DGGML_NATIVE=OFF \
|
||||
-DCMAKE_CUDA_ARCHITECTURES="$CUDA_ARCHITECTURES" && \
|
||||
cmake --build /src/llama.cpp/build --target llama-server -j "$(nproc)"
|
||||
|
||||
FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_VERSION}
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG LLAMA_CPP_COMMIT
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl libcurl4 libgomp1 python3 && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
useradd --system --uid 10003 --home /nonexistent --shell /usr/sbin/nologin llama
|
||||
COPY --from=build /src/llama.cpp/build/bin/ /opt/llama/bin/
|
||||
COPY platform/web-search/web_search_mcp.py /opt/mike-ai/mcp/web_search_mcp.py
|
||||
LABEL org.opencontainers.image.source="https://github.com/ggml-org/llama.cpp" \
|
||||
com.mike-ai.llama-cpp-commit="$LLAMA_CPP_COMMIT"
|
||||
ENV LD_LIBRARY_PATH=/opt/llama/bin
|
||||
USER 10003:10003
|
||||
ENTRYPOINT ["/opt/llama/bin/llama-server"]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"web": {
|
||||
"command": "/usr/bin/python3",
|
||||
"args": ["/opt/mike-ai/mcp/web_search_mcp.py"],
|
||||
"env": {
|
||||
"SEARXNG_URL": "http://searxng:8080"
|
||||
},
|
||||
"timeout_ms": 120000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM python:3.13.7-slim
|
||||
COPY profile_controller.py /app/profile_controller.py
|
||||
ENTRYPOINT ["python", "/app/profile_controller.py"]
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Strict Docker profile switcher for the local AI stack.
|
||||
|
||||
Only status and activation of a fixed set of labelled llama.cpp containers are
|
||||
exposed. Callers cannot provide images, commands, mounts or Docker API paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HOST = os.environ.get("CONTROLLER_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("CONTROLLER_PORT", "8090"))
|
||||
SOCKET_PATH = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
|
||||
TOKEN_FILE = os.environ.get("CONTROLLER_TOKEN_FILE", "/run/secrets/controller-token")
|
||||
ALLOWED = tuple(x.strip() for x in os.environ.get(
|
||||
"ALLOWED_PROFILES", "fast,medium,long,experimental").split(",") if x.strip())
|
||||
LABEL_KEY = "com.mike-ai.llama-profile"
|
||||
LOCK = threading.Lock()
|
||||
log = logging.getLogger("profile-controller")
|
||||
|
||||
|
||||
class UnixConnection(http.client.HTTPConnection):
|
||||
def connect(self) -> None:
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.sock.connect(SOCKET_PATH)
|
||||
|
||||
|
||||
def docker_request(method: str, path: str) -> tuple[int, bytes]:
|
||||
conn = UnixConnection("localhost", timeout=30)
|
||||
try:
|
||||
conn.request(method, path, headers={"Content-Type": "application/json"})
|
||||
response = conn.getresponse()
|
||||
return response.status, response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def containers() -> dict[str, dict]:
|
||||
filters = urllib.parse.quote(json.dumps({"label": [LABEL_KEY]}))
|
||||
status, body = docker_request("GET", f"/containers/json?all=1&filters={filters}")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Docker list failed with HTTP {status}")
|
||||
result: dict[str, dict] = {}
|
||||
for item in json.loads(body):
|
||||
profile = item.get("Labels", {}).get(LABEL_KEY)
|
||||
if profile in ALLOWED:
|
||||
if profile in result:
|
||||
raise RuntimeError(f"duplicate container for profile {profile}")
|
||||
result[profile] = item
|
||||
return result
|
||||
|
||||
|
||||
def active_profile(items: dict[str, dict] | None = None) -> str | None:
|
||||
items = items or containers()
|
||||
active = [name for name, item in items.items() if item.get("State") == "running"]
|
||||
if len(active) > 1:
|
||||
raise RuntimeError(f"multiple llama profiles active: {', '.join(active)}")
|
||||
return active[0] if active else None
|
||||
|
||||
|
||||
def activate(profile: str) -> dict:
|
||||
if profile not in ALLOWED:
|
||||
raise ValueError("profile is not allowlisted")
|
||||
with LOCK:
|
||||
items = containers()
|
||||
missing = [name for name in ALLOWED if name not in items]
|
||||
if missing:
|
||||
raise RuntimeError("profile containers missing: " + ", ".join(missing))
|
||||
current = active_profile(items)
|
||||
if current == profile:
|
||||
return {"active_profile": current, "changed": False}
|
||||
for name, item in items.items():
|
||||
if name == profile or item.get("State") != "running":
|
||||
continue
|
||||
status, _ = docker_request("POST", f"/containers/{item['Id']}/stop?t=120")
|
||||
if status not in (204, 304):
|
||||
raise RuntimeError(f"failed to stop profile {name}: HTTP {status}")
|
||||
target = items[profile]
|
||||
status, _ = docker_request("POST", f"/containers/{target['Id']}/start")
|
||||
if status not in (204, 304):
|
||||
raise RuntimeError(f"failed to start profile {profile}: HTTP {status}")
|
||||
log.info("activated profile %s (previous=%s)", profile, current)
|
||||
return {"active_profile": profile, "changed": True}
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
token = os.environ.get("CONTROLLER_TOKEN", "").strip()
|
||||
if not token:
|
||||
with open(TOKEN_FILE, encoding="utf-8") as handle:
|
||||
token = handle.read().strip()
|
||||
if len(token) < 32:
|
||||
raise RuntimeError("controller token is missing or too short")
|
||||
return token
|
||||
|
||||
|
||||
TOKEN = load_token()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "mike-ai-profile-controller/1"
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
log.info("%s - %s", self.client_address[0], fmt % args)
|
||||
|
||||
def reply(self, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def authenticated(self) -> bool:
|
||||
return self.headers.get("Authorization", "") == f"Bearer {TOKEN}"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path == "/health":
|
||||
self.reply(200, {"status": "ok"})
|
||||
return
|
||||
if self.path != "/status":
|
||||
self.reply(404, {"error": "not found"})
|
||||
return
|
||||
if not self.authenticated():
|
||||
self.reply(401, {"error": "unauthorized"})
|
||||
return
|
||||
try:
|
||||
items = containers()
|
||||
self.reply(200, {"active_profile": active_profile(items),
|
||||
"profiles": {name: items.get(name, {}).get(
|
||||
"State", "missing") for name in ALLOWED}})
|
||||
except Exception as exc:
|
||||
log.exception("status failed")
|
||||
self.reply(503, {"error": str(exc)})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if not self.authenticated():
|
||||
self.reply(401, {"error": "unauthorized"})
|
||||
return
|
||||
prefix, suffix = "/profiles/", "/activate"
|
||||
if not self.path.startswith(prefix) or not self.path.endswith(suffix):
|
||||
self.reply(404, {"error": "not found"})
|
||||
return
|
||||
profile = self.path[len(prefix):-len(suffix)]
|
||||
if profile not in ALLOWED:
|
||||
self.reply(400, {"error": "profile is not allowlisted"})
|
||||
return
|
||||
try:
|
||||
self.reply(200, activate(profile))
|
||||
except Exception as exc:
|
||||
log.exception("activation failed for %s", profile)
|
||||
self.reply(503, {"error": str(exc)})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.13.7-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gosu && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
useradd --system --uid 10002 --home /nonexistent --shell /usr/sbin/nologin router
|
||||
WORKDIR /app
|
||||
COPY router/ai_profile_router.py router/router_support.py /app/
|
||||
COPY platform/docker/router/entrypoint.sh /usr/local/bin/router-entrypoint
|
||||
RUN chmod 0755 /usr/local/bin/router-entrypoint
|
||||
ENTRYPOINT ["router-entrypoint"]
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
chown 10002:10002 /var/lib/mike-ai-profile-router /data/images
|
||||
exec gosu 10002:10002 python /app/ai_profile_router.py
|
||||
@@ -2,10 +2,11 @@ schema: 1
|
||||
models:
|
||||
qwen_fast_long:
|
||||
role: primary-text-fast-and-long
|
||||
source: "REPLACE_WITH_MODEL_REPOSITORY"
|
||||
source: "local migration from the reference host; public origin still to document"
|
||||
file: Qwen3.8-27B-IQ4-MIX.gguf
|
||||
target: /opt/mike-ai/models/qwen3.8-27b-iq4-mix/Qwen3.8-27B-IQ4-MIX.gguf
|
||||
sha256: "REPLACE_AFTER_VERIFICATION"
|
||||
sha256: "54879ae8738d5938f46cb3b8cbf16bf42b8c85b7d68d7c73f062b612ec183e36"
|
||||
size_bytes: 14111614400
|
||||
qwen_medium:
|
||||
role: primary-text-medium
|
||||
source: "REPLACE_WITH_MODEL_REPOSITORY"
|
||||
|
||||
@@ -31,6 +31,7 @@ SERVER_VERSION = "2.1.0"
|
||||
TINYSEARCH_CONTAINER = os.environ.get(
|
||||
"TINYSEARCH_CONTAINER", "mike-ai-web-search-tinysearch-1"
|
||||
)
|
||||
SEARXNG_URL = os.environ.get("SEARXNG_URL", "").rstrip("/")
|
||||
CHILD_TIMEOUT_SECONDS = float(os.environ.get("TINYSEARCH_CHILD_TIMEOUT", "110"))
|
||||
HTTP_TIMEOUT_SECONDS = float(os.environ.get("WEB_API_TIMEOUT", "18"))
|
||||
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "").strip()
|
||||
@@ -349,6 +350,31 @@ def api_json(url: str, service: str) -> Any:
|
||||
return decoded
|
||||
|
||||
|
||||
def searxng_json(query: str) -> Any:
|
||||
"""Query only the administrator-configured internal SearXNG endpoint.
|
||||
|
||||
Public API fetches intentionally reject private addresses. SearXNG is the
|
||||
one explicit internal exception; callers cannot influence its scheme,
|
||||
authority or path, only the encoded search term.
|
||||
"""
|
||||
base = urlparse(SEARXNG_URL)
|
||||
if base.scheme not in {"http", "https"} or not base.hostname:
|
||||
raise RuntimeError("invalid configured SearXNG URL")
|
||||
url = f"{SEARXNG_URL}/search?" + urlencode({
|
||||
"q": query, "format": "json", "language": "auto"})
|
||||
try:
|
||||
with urlopen(Request(url, headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": f"mike-ai-web/{SERVER_VERSION}",
|
||||
}), timeout=HTTP_TIMEOUT_SECONDS) as response:
|
||||
payload = response.read(2_000_000)
|
||||
except HTTPError as exc:
|
||||
raise RuntimeError(f"searxng returned HTTP {exc.code}") from exc
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise RuntimeError("searxng unavailable") from exc
|
||||
return json.loads(payload.decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
def infer_backend(query: str, requested: str = "auto") -> str:
|
||||
if requested not in {"auto", "web", "github", "huggingface"}:
|
||||
raise ValueError("backend must be auto, web, github or huggingface")
|
||||
@@ -746,7 +772,19 @@ def general_discovery(query: str, limit: int) -> tuple[list[dict[str, Any]], lis
|
||||
results: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
try:
|
||||
results.extend(parse_search_xml(client().call("search", {"query": query}), limit))
|
||||
if SEARXNG_URL:
|
||||
data = searxng_json(query)
|
||||
for row in (data.get("results") or [])[:limit]:
|
||||
results.append({
|
||||
"title": clean_text(str(row.get("title", "")), 240),
|
||||
"url": str(row.get("url", "")),
|
||||
"preview": clean_text(str(row.get("content", "")), 500),
|
||||
"source": "searxng",
|
||||
"published_at": row.get("publishedDate") or row.get("published_date"),
|
||||
})
|
||||
else:
|
||||
results.extend(parse_search_xml(
|
||||
client().call("search", {"query": query}), limit))
|
||||
except Exception as exc:
|
||||
warnings.append(clean_text(str(exc), 240))
|
||||
if len(results) < limit and BRAVE_SEARCH_API_KEY:
|
||||
|
||||
+77
-20
@@ -92,6 +92,19 @@ UPSTREAM_URL = os.environ.get("UPSTREAM_URL", "http://127.0.0.1:8080").rstrip("/
|
||||
PROFILE_SCRIPT = os.environ.get("PROFILE_SCRIPT", "/usr/local/bin/llama-profile")
|
||||
PROFILE_DIR = os.environ.get(
|
||||
"PROFILE_DIR", "/etc/systemd/system/mike-ai-llama-ui.service.d")
|
||||
PROFILE_CONTROL_URL = os.environ.get("PROFILE_CONTROL_URL", "").rstrip("/")
|
||||
PROFILE_CONTROL_TOKEN_FILE = os.environ.get(
|
||||
"PROFILE_CONTROL_TOKEN_FILE", "/run/secrets/controller-token")
|
||||
|
||||
# Optional worker APIs. The clean Docker baseline deliberately ships only
|
||||
# text/multimodal chat; absent workers must fail explicitly instead of trying
|
||||
# legacy systemd paths inside the container.
|
||||
ENABLE_IMAGE_GENERATION = os.environ.get(
|
||||
"ENABLE_IMAGE_GENERATION", "true").lower() in {"1", "true", "yes"}
|
||||
ENABLE_TTS = os.environ.get(
|
||||
"ENABLE_TTS", "true").lower() in {"1", "true", "yes"}
|
||||
ENABLE_STT = os.environ.get(
|
||||
"ENABLE_STT", "true").lower() in {"1", "true", "yes"}
|
||||
|
||||
SWITCH_TIMEOUT = float(os.environ.get("SWITCH_TIMEOUT", "600")) # s, Warten auf llama.cpp
|
||||
REQUEST_TIMEOUT = float(os.environ.get("REQUEST_TIMEOUT", "600")) # s, Read-Timeout Upstream
|
||||
@@ -420,12 +433,40 @@ def _read(path: str) -> str:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
def _profile_controller_request(method: str, path: str) -> dict:
|
||||
token = os.environ.get("PROFILE_CONTROL_TOKEN", "").strip()
|
||||
if not token:
|
||||
token = _read(PROFILE_CONTROL_TOKEN_FILE)
|
||||
if len(token) < 32:
|
||||
raise RuntimeError("Profil-Controller-Token fehlt oder ist zu kurz")
|
||||
request = urllib.request.Request(
|
||||
PROFILE_CONTROL_URL + path,
|
||||
method=method,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read(500).decode(errors="replace")
|
||||
raise RuntimeError(
|
||||
f"Profil-Controller HTTP {exc.code}: {body}") from exc
|
||||
|
||||
|
||||
def current_profile() -> str | None:
|
||||
"""Aktives Profil anhand semantischer Werte der override.conf.
|
||||
|
||||
Kommentare, Leerraum oder die Reihenfolge anderer llama.cpp-Optionen
|
||||
beeinflussen die Erkennung nicht mehr.
|
||||
"""
|
||||
if PROFILE_CONTROL_URL:
|
||||
try:
|
||||
profile = _profile_controller_request("GET", "/status").get(
|
||||
"active_profile")
|
||||
return profile if profile in PROFILES else None
|
||||
except Exception as exc:
|
||||
log.warning("Profil-Controller-Status nicht verfügbar: %s", exc)
|
||||
return None
|
||||
try:
|
||||
override = _read(os.path.join(PROFILE_DIR, "override.conf"))
|
||||
except OSError:
|
||||
@@ -512,23 +553,27 @@ def switch_profile(profile: str, implicit: bool = False) -> None:
|
||||
f"llama.cpp nicht erreichbar (Profil {profile} ist "
|
||||
f"bereits aktiv; Neustart über /{profile})")
|
||||
log.info("Profilwechsel: %s -> %s", cur, profile)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[PROFILE_SCRIPT, profile],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=120,
|
||||
)
|
||||
out = proc.stdout.decode(errors="replace").strip()
|
||||
if out:
|
||||
log.info("llama-profile: %s", out[-500:])
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"llama-profile fehlgeschlagen (Exit-Code "
|
||||
f"{proc.returncode}): {out[-500:]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("llama-profile hat 120 s überschritten")
|
||||
if PROFILE_CONTROL_URL:
|
||||
_profile_controller_request(
|
||||
"POST", f"/profiles/{profile}/activate")
|
||||
else:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[PROFILE_SCRIPT, profile],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=120,
|
||||
)
|
||||
out = proc.stdout.decode(errors="replace").strip()
|
||||
if out:
|
||||
log.info("llama-profile: %s", out[-500:])
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"llama-profile fehlgeschlagen (Exit-Code "
|
||||
f"{proc.returncode}): {out[-500:]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("llama-profile hat 120 s überschritten")
|
||||
if current_profile() != profile:
|
||||
raise RuntimeError(
|
||||
f"Profildatei wurde nicht gesetzt (erwartet: {profile})")
|
||||
@@ -1041,11 +1086,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif path == "/v1/audio/voices" and self.command == "GET":
|
||||
self._send_json(200, self._audio_voices_payload())
|
||||
elif path == "/v1/images/generations" and self.command == "POST":
|
||||
self._image_generate()
|
||||
if ENABLE_IMAGE_GENERATION:
|
||||
self._image_generate()
|
||||
else:
|
||||
self._send_error(503, "Bildgenerierung ist nicht installiert",
|
||||
"server_error", "feature_disabled")
|
||||
elif path == "/v1/audio/speech" and self.command == "POST":
|
||||
self._speech()
|
||||
if ENABLE_TTS:
|
||||
self._speech()
|
||||
else:
|
||||
self._send_error(503, "Sprachausgabe ist nicht installiert",
|
||||
"server_error", "feature_disabled")
|
||||
elif path == "/v1/audio/transcriptions" and self.command == "POST":
|
||||
self._transcribe()
|
||||
if ENABLE_STT:
|
||||
self._transcribe()
|
||||
else:
|
||||
self._send_error(503, "Spracherkennung ist nicht installiert",
|
||||
"server_error", "feature_disabled")
|
||||
elif path == "/images" and self.command == "GET":
|
||||
self._images_list()
|
||||
elif path.startswith("/images/") and self.command == "GET":
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
"profiles": {
|
||||
"fast": {
|
||||
"context": 76800,
|
||||
"model_alias": "qwen38-27b-iq4mix-76k-mtp2-vision"
|
||||
"model_alias": "qwen-fast"
|
||||
},
|
||||
"medium": {
|
||||
"context": 94208,
|
||||
"model_alias": "qwen38-27b-iq4xs-pure-92k"
|
||||
"model_alias": "qwen-medium"
|
||||
},
|
||||
"long": {
|
||||
"context": 131072,
|
||||
"model_alias": "qwen38-27b-iq4mix-128k-mtp2-ffn12"
|
||||
"model_alias": "qwen-long"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user