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
|
*.bin
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
config/install.env
|
||||||
|
platform/web-search/searxng-settings.yml
|
||||||
|
|||||||
@@ -1,592 +1,83 @@
|
|||||||
# Lokale KI-Plattform
|
# Lokale KI-Plattform
|
||||||
|
|
||||||
Dieses private Repository dokumentiert und installiert die reproduzierbare
|
Reproduzierbarer Docker-Stack für einen privaten Qwen-/llama.cpp-Host mit
|
||||||
KI-Umgebung rund um einen lokalen `llama.cpp`-Host. Der **AI Profile Router**
|
Open WebUI, Profilumschaltung, integrierter Vision, lokaler Websuche und
|
||||||
bleibt der zentrale Bestandteil, ist aber nicht mehr die einzige Komponente.
|
WireGuard-Isolation.
|
||||||
|
|
||||||
## Plattform auf einen Blick
|
## Zielbild
|
||||||
|
|
||||||
```text
|
- Debian 13 als schlanker GPU-Host
|
||||||
Clients (Open WebUI, Hermes, Apps)
|
- llama.cpp selbst gebaut und auf einen geprüften Commit festgelegt
|
||||||
|
|
- vier getrennte Profilcontainer, davon immer exakt einer aktiv
|
||||||
v
|
- `/fast`, `/medium`, `/long` und `/experimental` über den Profile Router
|
||||||
AI Profile Router :8081
|
- Open WebUI als einzige normale Oberfläche
|
||||||
|-- qwen-fast (76.8K, maximale Geschwindigkeit)
|
- SearXNG/Web-MCP ohne externen API-Schlüssel
|
||||||
|-- qwen-medium (92K, reines IQ4_XS)
|
- KI-Dienste ausschließlich über die WireGuard-Adresse erreichbar
|
||||||
|-- qwen-long (128K, CPU-Offload)
|
- KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed
|
||||||
|-- integrierte Qwen-Vision (kein Hotswap)
|
- keine Secrets, Chats, Logs oder Modelldateien im Repository
|
||||||
|-- lokale Bildgenerierung
|
|
||||||
|-- Whisper STT
|
## Schnellstart
|
||||||
`-- XTTS TTS
|
|
||||||
|
|
Auf einem frisch installierten Debian 12/13 amd64:
|
||||||
v
|
|
||||||
llama.cpp :8080 + profilabhängige MCP-Server
|
```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
|
## Dienste
|
||||||
- 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
|
|
||||||
|
|
||||||
### 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
|
Bildgenerierung, TTS/STT sowie Home-Assistant-, ARR- und Unraid-MCPs werden
|
||||||
- API-Schlüssel, Tokens, SSH-Schlüssel oder Zertifikate
|
bewusst nicht automatisch aktiviert. Sie erhalten später eigene Container und
|
||||||
- Chats, Prompts, Logs, Bilder oder Audiodateien
|
kleinstmögliche Rechte. Die Bildanalyse ist bereits Bestandteil des
|
||||||
- alte Benchmarks, experimentelle Builds und ausgemusterte RX-Dienste
|
multimodalen Qwen-Modells.
|
||||||
- hostgebundene Backups und Cache-Verzeichnisse
|
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
- [Architektur](docs/ARCHITECTURE.md)
|
- [Roadmap für den neuen Host](docs/NEW_HOST_ROADMAP.md)
|
||||||
- [Komponentenverzeichnis](docs/COMPONENTS.md)
|
- [Zielarchitektur und Sicherheitsgrenzen](docs/ARCHITECTURE.md)
|
||||||
- [Aktueller produktiver Referenzstand](docs/CURRENT_REFERENCE.md)
|
- [Installation und Abnahme](docs/INSTALLATION.md)
|
||||||
- [Noch benötigte Wiederherstellungsartefakte](docs/RECOVERY_REQUIREMENTS.md)
|
- [WireGuard-Heimseite](docs/WIREGUARD_HOME_PEER.md)
|
||||||
- [Disaster Recovery und Abnahme](docs/DISASTER_RECOVERY.md)
|
|
||||||
- [Saubere Installation](docs/INSTALLATION.md)
|
|
||||||
- [Betrieb und Profilwechsel](docs/OPERATIONS.md)
|
- [Betrieb und Profilwechsel](docs/OPERATIONS.md)
|
||||||
- [Sicherheitsmodell](docs/SECURITY.md)
|
- [Sicherheitsmodell](docs/SECURITY.md)
|
||||||
- [Router V2: Migration und Kompatibilität](docs/ROUTER_V2_MIGRATION.md)
|
- [Disaster Recovery](docs/DISASTER_RECOVERY.md)
|
||||||
- [Migration vom bestehenden Host](docs/MIGRATION.md)
|
- [Komponenten](docs/COMPONENTS.md)
|
||||||
- [MCP-Aufteilung](platform/mcp/README.md)
|
|
||||||
- [llama.cpp-Build und Profile](platform/llama/README.md)
|
|
||||||
|
|
||||||
## AI Profile Router
|
## Wichtige Dateien
|
||||||
|
|
||||||
Kleiner OpenAI-kompatibler Proxy (Python, nur Standardbibliothek) vor einem
|
```text
|
||||||
lokalen llama.cpp-Server. Er leitet normale OpenAI-Requests transparent
|
install.sh kompletter Bootstrap
|
||||||
weiter (Streaming, Tool Calls, JSON und Bilder), schaltet zwischen drei festen
|
config/install.env.example öffentliche Konfigurationsvorlage
|
||||||
multimodalen llama.cpp-Profilen um, orchestriert lokale Bildgenerierung mit
|
compose.yaml produktiver Stack
|
||||||
FLUX.2 [klein] 4B Base (GPU-Hotswap: Qwen stoppen → FLUX laden → Bild
|
platform/docker/llama-cpp/Dockerfile CUDA-llama.cpp-Build
|
||||||
→ FLUX entladen → Qwen wiederherstellen) und stellt lokale deutsche
|
platform/docker/profile-controller/ sichere Profilsteuerung
|
||||||
Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel).
|
router/ OpenAI-kompatibler Profile Router
|
||||||
|
|
||||||
## 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"}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Antwort enthält `data[].url` (absolute URL, über den Router abrufbar)
|
## Sicherheitsregeln
|
||||||
und `data[].b64_json` (optional). Jedes Bild wird unter
|
|
||||||
`/opt/mike-ai/ai-profile-router/images/` gespeichert (kollisionsfreie Namen,
|
- `config/install.env` ist lokal, Modus 0600, und wird ignoriert.
|
||||||
`img-<zeitstempel>-<seed>-<i>.png`) und ist über `GET /images/<datei>`
|
- API-, Controller-, WebUI- und WireGuard-Schlüssel entstehen erst am Host.
|
||||||
abrufbar.
|
- Nur der kleine Profile Controller sieht den Docker-Socket.
|
||||||
|
- llama.cpp veröffentlicht weder Port noch WebUI.
|
||||||
### Verhalten während eines Bild-Jobs
|
- Ein Blackhole-Fallback verhindert Traffic-Leaks bei WireGuard-Ausfall.
|
||||||
|
- Das Uni-Netz und das Heimnetz dürfen diesen Host nicht als Transit benutzen.
|
||||||
- **Chat-Requests warten** (kein 502): Der Router merkt sich, dass Qwen
|
|
||||||
vorübergehend nicht verfügbar ist (`qwen.available=false`), und Chat-Requests
|
Die Profilwerte sind Ausgangswerte. Nach dem Neuaufbau werden RTX 5080 und
|
||||||
warten, bis Qwen wieder bereit ist (Timeout `CHAT_WAIT_TIMEOUT`, Default
|
RTX 3060 mit der bestehenden Standard-Testserie neu vermessen, bevor die zweite
|
||||||
300 s). So gibt es keine `502 llama.cpp nicht erreichbar` während des
|
GPU in ein Produktionsprofil einfließt.
|
||||||
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`.
|
|
||||||
|
|||||||
+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
|
Der Host läuft auf Debian 13. Das Betriebssystem darf im Universitätsnetz
|
||||||
bereit. Jede Komponente hat genau eine Aufgabe und kann unabhängig ersetzt
|
administrierbar bleiben; die KI-Plattform wird ausschließlich an die
|
||||||
werden. Ein Neuaufbau darf keine Dateien vom alten Host voraussetzen, die nicht
|
WireGuard-Adresse gebunden. KI-Container erreichen Heimnetz und Internet über
|
||||||
in diesem Repository oder im Modellmanifest beschrieben sind.
|
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 |
|
## Container und Vertrauensgrenzen
|
||||||
|---|---:|---|---|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## 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.
|
Nur der Profile Controller erhält den Docker-Socket. Der Router erhält weder
|
||||||
2. Der Router veröffentlicht `qwen-fast`, `qwen-medium` und `qwen-long`.
|
Socket noch Shell-Zugriff und kann dem Controller nur `fast`, `medium`, `long`
|
||||||
3. Passt das aktive Profil nicht zum virtuellen Modell, wird llama.cpp kontrolliert
|
oder `experimental` übergeben. Die llama-Container laufen ohne UI,
|
||||||
mit dem passenden Profil neu gestartet.
|
Capabilities und Schreibzugriff auf die Modelldateien.
|
||||||
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.
|
|
||||||
|
|
||||||
## Profilprinzip
|
## Profilprinzip
|
||||||
|
|
||||||
Die Profile sind vollständige systemd-Overrides. Ein Profilwechsel kopiert die
|
Alle Profile verwenden dasselbe selbst gebaute llama.cpp-Image. Separate,
|
||||||
gewählte Datei atomar auf `override.conf`, lädt systemd neu und startet genau
|
normalerweise gestoppte Containerdefinitionen halten Parameter wie Kontext,
|
||||||
einen llama.cpp-Dienst neu. Es gibt niemals mehrere Textmodelle gleichzeitig.
|
MTP und CPU-Offload reproduzierbar. Ein Wechsel stoppt das alte Profil und
|
||||||
Das unabhängige Register `/etc/mike-ai/router-profiles.json` definiert Kontext
|
startet genau einen bereits angelegten Container. Dadurch lassen sich Profile
|
||||||
und erwarteten Modellalias. Readiness gilt nur, wenn beides exakt passt; eine
|
einzeln verändern oder duplizieren, ohne vier Modelle parallel im VRAM zu
|
||||||
abweichende oder fehlende Registry verhindert den Start.
|
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,
|
## Netzwerk
|
||||||
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.
|
|
||||||
|
|
||||||
Der zuletzt stabile Zustand und temporäre Worker-PIDs werden atomar unter
|
- Docker-Netze liegen ausschließlich unter `172.30.0.0/16`.
|
||||||
`/var/lib/mike-ai-profile-router/state.json` festgehalten. Beim Routerstart
|
- Open WebUI und Router binden an `AI_BIND_ADDRESS`, die WireGuard-IP.
|
||||||
werden ausschließlich dort erfasste Prozesse nach zusätzlicher
|
- Quellrouting schickt KI-Container in Tabelle 51820 über WireGuard.
|
||||||
Kommandozeilenprüfung beendet und das letzte Profil wiederhergestellt.
|
- 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
|
Bildgenerierung, Whisper, TTS sowie Home-Assistant-, ARR- und Unraid-MCPs sind
|
||||||
absichtlich anonym und enthalten keine privaten Daten.
|
Erweiterungen. Sie benötigen eigene Modelle, Rechte oder Secrets und bleiben
|
||||||
- Der Client-Key wird vor dem lokalen Upstream entfernt.
|
im sauberen Basissystem deaktiviert. Multimodale Bildanalyse erfolgt direkt
|
||||||
- Bild-Uploads sind begrenzt. Remote-Bild-URLs sind standardmäßig aus, damit
|
über Qwen plus Projektor. Nicht installierte Worker-Endpunkte antworten klar
|
||||||
der Vision-Pfad nicht als Zugriff auf Intranet oder Metadatenendpunkte dient.
|
mit `feature_disabled`, statt alte systemd-Pfade aufzurufen.
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ Zielplattform.
|
|||||||
### Aktives Fast-Profil
|
### Aktives Fast-Profil
|
||||||
|
|
||||||
- Qwen3.8-27B IQ4-MIX
|
- 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
|
- Kontext 76.800
|
||||||
- vollständig auf CUDA0
|
- vollständig auf CUDA0
|
||||||
- Flash Attention
|
- 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
|
Der automatisierte Weg ist `install.sh`. Das Skript ist für **Debian 12/13
|
||||||
automatisch.
|
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
|
1. Die Universität muss den ausgehenden WireGuard-Tunnel erlauben.
|
||||||
- NVIDIA-Treiber und funktionierendes `nvidia-smi`
|
2. Heimnetz, Universitätsnetz und Docker-Netz dürfen sich nicht überschneiden.
|
||||||
- Build-Werkzeuge, CMake, Git und CUDA Toolkit
|
3. Der WireGuard-Heim-Peer braucht eine feste öffentliche Adresse oder DNS.
|
||||||
- Python 3.13 für Router/FLUX und Python 3.11 für XTTS
|
4. Für KI-Internetzugang über zuhause: Forwarding und NAT am Heim-Peer.
|
||||||
- Docker plus Compose für Websuche
|
5. Das private Repository muss auf dem neuen Host lesbar sein.
|
||||||
- ausreichend freier Speicher; mindestens 15 Prozent auf `/`
|
|
||||||
|
|
||||||
## 2. Benutzer und Verzeichnisse
|
## Debian installieren
|
||||||
|
|
||||||
Für produktive Dienste sollen eigene Systembenutzer verwendet werden. Der
|
- Debian 13 minimal, amd64, OpenSSH-Server, kein Desktop erforderlich.
|
||||||
Router benötigt kontrollierte Berechtigung zum Neustart des llama.cpp-Dienstes;
|
- Einen normalen Administrationsbenutzer mit sudo anlegen.
|
||||||
er sollte nicht dauerhaft als root laufen.
|
- Optional bei physischem Fremdzugriff: LUKS-Verschlüsselung.
|
||||||
|
- BIOS: Above 4G Decoding aktiv; beide GPUs sichtbar machen.
|
||||||
|
|
||||||
```text
|
## Konfiguration
|
||||||
/opt/mike-ai/models
|
|
||||||
/opt/mike-ai/ai-profile-router
|
```bash
|
||||||
/etc/mike-ai
|
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
|
```bash
|
||||||
`platform/llama/LLAMA_CPP_COMMIT` hinterlegten Commit aus. Vor einem Upgrade:
|
sudo ./install.sh --config config/install.env
|
||||||
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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`
|
```ini
|
||||||
kopieren und nur benötigte Server aktivieren. Zugangsdaten werden ausschließlich
|
[Peer]
|
||||||
über lokale Environment-Dateien oder einen Secret Broker referenziert.
|
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,
|
## Ergebnis und Abnahme
|
||||||
Whisper und XTTS. Vor produktiver Verwendung müssen Modellpfade in der
|
|
||||||
systemd-Datei gegen das lokale Manifest geprüft werden.
|
|
||||||
|
|
||||||
Der Installer erzeugt `/etc/mike-ai/router-api-key` (0600), installiert das
|
- Open WebUI: `http://<WIREGUARD-IP>:8080`
|
||||||
Profilregister und legt die atomare Zustandsablage an. Danach wird der Key
|
- Router: `http://<WIREGUARD-IP>:8081`
|
||||||
einmal manuell in die Secret-Stores der erlaubten Clients übernommen. Er darf
|
- llama.cpp-WebUI: absichtlich deaktiviert und nicht veröffentlicht
|
||||||
nicht im Terminal-Log, in Screenshots oder in Git dokumentiert werden.
|
|
||||||
|
|
||||||
Der Router läuft aktuell als gehärteter Root-Dienst, weil er den
|
```bash
|
||||||
llama.cpp-Systemdienst und temporäre GPU-Worker kontrolliert. Das ist eine
|
sudo systemctl status wg-quick@wg0 mike-ai-network-guard
|
||||||
bewusste Restabweichung. Ein späterer V3-Schritt soll den HTTP-Proxy als eigenen
|
sudo docker compose --env-file /etc/mike-ai/stack.env \
|
||||||
Benutzer ausführen und nur Profil-/Hotswap-Befehle an einen fest
|
-f /opt/mike-ai/stack/compose.yaml ps
|
||||||
parametrisierten Root-Helper delegieren.
|
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
|
Die öffentliche Standardkonfiguration nutzt `UD-IQ4_XS`. Das bislang schnellste
|
||||||
Suchdienste sollen nur an localhost gebunden werden; nur der Web-MCP greift
|
Referenzprofil nutzt dagegen die lokal vorhandene `IQ4-MIX`-Datei. Für eine
|
||||||
darauf zu.
|
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
|
Open-WebUI-Daten liegen in einem Docker-Volume und müssen separat gesichert
|
||||||
|
werden. Geheimnisse und Chatdaten gehören nie in Git.
|
||||||
`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.
|
|
||||||
|
|||||||
@@ -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
|
- benötigte Environment-Namen ohne Werte
|
||||||
- Liste read-only und schreibender Werkzeuge
|
- 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
|
- Paketquellen und benötigte Debian-Pakete
|
||||||
- NVIDIA-Treiber und exakte Version
|
- NVIDIA-Treiber und exakte Version
|
||||||
- CUDA Toolkit und Buildabhängigkeiten
|
- CUDA Toolkit und Buildabhängigkeiten
|
||||||
- Docker und Compose
|
- Docker und Compose
|
||||||
- Python 3.13 und Python 3.11/uv
|
- die benötigten Container-Runtimes und Dienstbenutzer in Images
|
||||||
- Dienstbenutzer und Gruppen
|
|
||||||
- Verzeichnisse, Eigentümer und Dateirechte
|
- Verzeichnisse, Eigentümer und Dateirechte
|
||||||
- Firewallregeln
|
- Firewallregeln
|
||||||
- Journalgrößenlimit
|
- Journalgrößenlimit
|
||||||
@@ -91,7 +90,7 @@ Noch festzulegen:
|
|||||||
- Restore ohne Ausgabe der Werte in Terminal- oder Modellkontext
|
- Restore ohne Ausgabe der Werte in Terminal- oder Modellkontext
|
||||||
- Funktionstest mit ausschließlich Statuscode, niemals Tokenanzeige
|
- Funktionstest mit ausschließlich Statuscode, niemals Tokenanzeige
|
||||||
|
|
||||||
## 5. Netzwerk und DNS – offen
|
## 5. Netzwerk und DNS – Vorlage umgesetzt, Standortwerte offen
|
||||||
|
|
||||||
Dokumentiert werden müssen:
|
Dokumentiert werden müssen:
|
||||||
|
|
||||||
@@ -157,9 +156,9 @@ Festlegen, welche Daten persistent sein sollen:
|
|||||||
- Benchmarkresultate: eigenes Repository
|
- Benchmarkresultate: eigenes Repository
|
||||||
- Logs: ohne Prompt- und Tool-Antwortinhalte
|
- 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
|
```text
|
||||||
bootstrap-host
|
bootstrap-host
|
||||||
|
|||||||
+50
-60
@@ -1,73 +1,63 @@
|
|||||||
# Sicherheitsmodell
|
# Sicherheitsmodell
|
||||||
|
|
||||||
## Grundsatz
|
## Netzgrenze
|
||||||
|
|
||||||
Das lokale Modell erhält nur die Werkzeuge, die es für den aktuellen Modus
|
- KI-Ports binden ausschließlich an die WireGuard-IP.
|
||||||
benötigt. Lokalität allein ersetzt keine Zugriffskontrolle.
|
- 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 |
|
| Standard | lokale Websuche, harmlose Hilfsfunktionen |
|
||||||
| Home Assistant | HA-Administration plus Websuche |
|
| Home Assistant | eigener begrenzter HA-MCP |
|
||||||
| ARR | Sonarr/Radarr plus Websuche |
|
| ARR | Sonarr/Radarr, zuerst read-only |
|
||||||
| Unraid Read-only | Diagnose, Logs, Status |
|
| Unraid Diagnose | Status und eng begrenzte Logs |
|
||||||
| Unraid Write | nur bewusst aktiviert, mit Vorschau und Approval Ticket |
|
| Administration | Vorschau, Approval-Ticket, Verifikation |
|
||||||
|
|
||||||
## Nicht im Standardprofil
|
Allgemeine Shell, beliebiges SSH/SCP, freies `curl`, Docker-Administration und
|
||||||
|
Dateisystemsuche gehören nicht ins 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`.
|
|
||||||
|
|
||||||
## Schreibaktionen
|
## 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,
|
## Vor jedem Push
|
||||||
2. exakte Vorschau,
|
|
||||||
3. an diese Vorschau gebundenes Approval Ticket,
|
|
||||||
4. unveränderte Ausführung,
|
|
||||||
5. anschließende Verifikation.
|
|
||||||
|
|
||||||
## Repository-Prüfung vor jedem Push
|
- Private-Key-, Token-, Passwort- und API-Key-Muster suchen.
|
||||||
|
- Keine `.env`, Zertifikate, Logs, Bilder, Audio oder Modelle einchecken.
|
||||||
- Suche nach Token-, Passwort- und Private-Key-Mustern.
|
- Beispiele enthalten nur Platzhalter; interne Hostnamen nur wenn bewusst.
|
||||||
- Keine `.env`, Zertifikate, Logs, Bilder, Audio oder Modellartefakte.
|
- Änderungen am Controller und Netzwerkguard mit Tests und Review versehen.
|
||||||
- Keine echten internen API-Schlüssel in Beispielen.
|
|
||||||
|
|||||||
@@ -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:
|
models:
|
||||||
qwen_fast_long:
|
qwen_fast_long:
|
||||||
role: primary-text-fast-and-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
|
file: Qwen3.8-27B-IQ4-MIX.gguf
|
||||||
target: /opt/mike-ai/models/qwen3.8-27b-iq4-mix/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:
|
qwen_medium:
|
||||||
role: primary-text-medium
|
role: primary-text-medium
|
||||||
source: "REPLACE_WITH_MODEL_REPOSITORY"
|
source: "REPLACE_WITH_MODEL_REPOSITORY"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ SERVER_VERSION = "2.1.0"
|
|||||||
TINYSEARCH_CONTAINER = os.environ.get(
|
TINYSEARCH_CONTAINER = os.environ.get(
|
||||||
"TINYSEARCH_CONTAINER", "mike-ai-web-search-tinysearch-1"
|
"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"))
|
CHILD_TIMEOUT_SECONDS = float(os.environ.get("TINYSEARCH_CHILD_TIMEOUT", "110"))
|
||||||
HTTP_TIMEOUT_SECONDS = float(os.environ.get("WEB_API_TIMEOUT", "18"))
|
HTTP_TIMEOUT_SECONDS = float(os.environ.get("WEB_API_TIMEOUT", "18"))
|
||||||
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "").strip()
|
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "").strip()
|
||||||
@@ -349,6 +350,31 @@ def api_json(url: str, service: str) -> Any:
|
|||||||
return decoded
|
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:
|
def infer_backend(query: str, requested: str = "auto") -> str:
|
||||||
if requested not in {"auto", "web", "github", "huggingface"}:
|
if requested not in {"auto", "web", "github", "huggingface"}:
|
||||||
raise ValueError("backend must be auto, web, github or 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]] = []
|
results: list[dict[str, Any]] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
warnings.append(clean_text(str(exc), 240))
|
warnings.append(clean_text(str(exc), 240))
|
||||||
if len(results) < limit and BRAVE_SEARCH_API_KEY:
|
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_SCRIPT = os.environ.get("PROFILE_SCRIPT", "/usr/local/bin/llama-profile")
|
||||||
PROFILE_DIR = os.environ.get(
|
PROFILE_DIR = os.environ.get(
|
||||||
"PROFILE_DIR", "/etc/systemd/system/mike-ai-llama-ui.service.d")
|
"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
|
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
|
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()
|
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:
|
def current_profile() -> str | None:
|
||||||
"""Aktives Profil anhand semantischer Werte der override.conf.
|
"""Aktives Profil anhand semantischer Werte der override.conf.
|
||||||
|
|
||||||
Kommentare, Leerraum oder die Reihenfolge anderer llama.cpp-Optionen
|
Kommentare, Leerraum oder die Reihenfolge anderer llama.cpp-Optionen
|
||||||
beeinflussen die Erkennung nicht mehr.
|
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:
|
try:
|
||||||
override = _read(os.path.join(PROFILE_DIR, "override.conf"))
|
override = _read(os.path.join(PROFILE_DIR, "override.conf"))
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -512,23 +553,27 @@ def switch_profile(profile: str, implicit: bool = False) -> None:
|
|||||||
f"llama.cpp nicht erreichbar (Profil {profile} ist "
|
f"llama.cpp nicht erreichbar (Profil {profile} ist "
|
||||||
f"bereits aktiv; Neustart über /{profile})")
|
f"bereits aktiv; Neustart über /{profile})")
|
||||||
log.info("Profilwechsel: %s -> %s", cur, profile)
|
log.info("Profilwechsel: %s -> %s", cur, profile)
|
||||||
try:
|
if PROFILE_CONTROL_URL:
|
||||||
proc = subprocess.run(
|
_profile_controller_request(
|
||||||
[PROFILE_SCRIPT, profile],
|
"POST", f"/profiles/{profile}/activate")
|
||||||
stdin=subprocess.DEVNULL,
|
else:
|
||||||
stdout=subprocess.PIPE,
|
try:
|
||||||
stderr=subprocess.STDOUT,
|
proc = subprocess.run(
|
||||||
timeout=120,
|
[PROFILE_SCRIPT, profile],
|
||||||
)
|
stdin=subprocess.DEVNULL,
|
||||||
out = proc.stdout.decode(errors="replace").strip()
|
stdout=subprocess.PIPE,
|
||||||
if out:
|
stderr=subprocess.STDOUT,
|
||||||
log.info("llama-profile: %s", out[-500:])
|
timeout=120,
|
||||||
if proc.returncode != 0:
|
)
|
||||||
raise RuntimeError(
|
out = proc.stdout.decode(errors="replace").strip()
|
||||||
f"llama-profile fehlgeschlagen (Exit-Code "
|
if out:
|
||||||
f"{proc.returncode}): {out[-500:]}")
|
log.info("llama-profile: %s", out[-500:])
|
||||||
except subprocess.TimeoutExpired:
|
if proc.returncode != 0:
|
||||||
raise RuntimeError("llama-profile hat 120 s überschritten")
|
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:
|
if current_profile() != profile:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Profildatei wurde nicht gesetzt (erwartet: {profile})")
|
f"Profildatei wurde nicht gesetzt (erwartet: {profile})")
|
||||||
@@ -1041,11 +1086,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif path == "/v1/audio/voices" and self.command == "GET":
|
elif path == "/v1/audio/voices" and self.command == "GET":
|
||||||
self._send_json(200, self._audio_voices_payload())
|
self._send_json(200, self._audio_voices_payload())
|
||||||
elif path == "/v1/images/generations" and self.command == "POST":
|
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":
|
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":
|
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":
|
elif path == "/images" and self.command == "GET":
|
||||||
self._images_list()
|
self._images_list()
|
||||||
elif path.startswith("/images/") and self.command == "GET":
|
elif path.startswith("/images/") and self.command == "GET":
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"fast": {
|
"fast": {
|
||||||
"context": 76800,
|
"context": 76800,
|
||||||
"model_alias": "qwen38-27b-iq4mix-76k-mtp2-vision"
|
"model_alias": "qwen-fast"
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"context": 94208,
|
"context": 94208,
|
||||||
"model_alias": "qwen38-27b-iq4xs-pure-92k"
|
"model_alias": "qwen-medium"
|
||||||
},
|
},
|
||||||
"long": {
|
"long": {
|
||||||
"context": 131072,
|
"context": 131072,
|
||||||
"model_alias": "qwen38-27b-iq4mix-128k-mtp2-ffn12"
|
"model_alias": "qwen-long"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user