From 18445515348a769ae988b7c838a593f2fb85d72f Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Mon, 7 Sep 2026 15:42:45 +0200
Subject: [PATCH] Add dual-GPU FLUX 9B image pipeline
---
.env.example | 3 +-
ATHENA.md | 9 +-
README.md | 29 +-
compose.yaml | 20 +-
config/install.env.example | 6 +-
dev/test_profile_controller.py | 12 +-
docs/ARCHITECTURE.md | 29 +-
docs/CURRENT_RUNTIME_NOTES.md | 25 +-
docs/FLUX_9B_BETA.md | 153 ++++++++++
docs/RECOVERY.md | 6 +
install.sh | 31 +-
integrations/hermes-athena-image/README.md | 46 +++
integrations/hermes-athena-image/__init__.py | 260 ++++++++++++++++
integrations/hermes-athena-image/plugin.yaml | 7 +
platform/docker/image-worker/Dockerfile | 4 +-
.../docker/image-worker/image_worker_9b.py | 282 ++++++++++++++++++
.../profile-controller/profile_controller.py | 31 +-
platform/models/manifest.example.yaml | 21 +-
router/ai_profile_router.py | 22 +-
19 files changed, 945 insertions(+), 51 deletions(-)
create mode 100644 docs/FLUX_9B_BETA.md
create mode 100644 integrations/hermes-athena-image/README.md
create mode 100644 integrations/hermes-athena-image/__init__.py
create mode 100644 integrations/hermes-athena-image/plugin.yaml
create mode 100644 platform/docker/image-worker/image_worker_9b.py
diff --git a/.env.example b/.env.example
index 288d6a7..ff7c69f 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,8 @@ AI_BIND_ADDRESS=10.77.0.2
MODEL_DIR=/data/models
ROUTER_API_KEY=GENERATED_BY_INSTALLER
CONTROLLER_TOKEN=GENERATED_BY_INSTALLER
-FLUX_MODEL_DIR=/data/models/FLUX.2-klein-4B
+FLUX_COMPONENT_DIR=/data/models/FLUX.2-klein-9B-components
+FLUX_TRANSFORMER_DIR=/data/models/FLUX.2-klein-9B-fp8
IMAGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
PIPER_TTS_VERSION=1.6.0
PIPER_VOICE=de_DE-thorsten-high
diff --git a/ATHENA.md b/ATHENA.md
index 6057178..5da812b 100644
--- a/ATHENA.md
+++ b/ATHENA.md
@@ -10,7 +10,7 @@ Sie betreibt:
- llama.cpp mit genau einem aktiven Qwen-Profil,
- den OpenAI-kompatiblen Profile Router,
-- FLUX.2-klein-4B für Textbilder und Referenzbild-Bearbeitung,
+- FLUX.2 Klein 9B FP8 Beta für Textbilder und Referenzbild-Bearbeitung,
- Qwen3-TTS und Piper für Sprache,
- das Athena-Dashboard,
- Portainer CE als optionale Ansicht auf die laufenden Docker-Container,
@@ -56,8 +56,11 @@ Qwen-Profil wird vom Profile Controller verwaltet.
- Fast: kurze, interaktive Aufgaben
- Medium/Large/Ultra: steigende Kontextgrößen desselben lokalen Qwen-Modells
- Uncensored: separates lokales Profil
-- FLUX.2-klein-4B: Bildgenerierung und Editing; Qwen wird dafür kurz entladen und danach
- automatisch wiederhergestellt
+- FLUX.2 Klein 9B FP8 Beta: Der Transformer läuft auf der RTX 5080, der
+ Qwen3-8B-NF4-Textencoder vorübergehend auf der RTX 3060. Das aktive
+ llama.cpp-Profil und Qwen3-TTS werden dafür gestoppt und danach automatisch
+ wiederhergestellt. Die Beta arbeitet mit 1024 × 1024 Pixeln, vier Schritten
+ und Guidance 1,0.
- Qwen3-TTS 1.7B: RTX 3060; Piper bleibt CPU-Fallback. Der Router reicht
zusätzlich natives 24-kHz-PCM für den optionalen Hermes-Streaming-Adapter
unter `integrations/hermes-qwen3-stream` durch.
diff --git a/README.md b/README.md
index f92e93b..c0a5aa9 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@ Bild- und Sprachausgabe. **Hermes und die Fach-MCPs laufen auf Unraid.**
- genau ein aktives llama.cpp-Profil: Fast, Medium, Large, Ultra oder Uncensored
- Profile Router auf Port 8081
-- FLUX.2-klein-4B für Textbilder und Referenzbild-Bearbeitung auf der RTX 5080
+- FLUX.2 Klein 9B FP8 Beta für Textbilder und Referenzbild-Bearbeitung:
+ Transformer auf RTX 5080, Qwen3-8B-NF4-Textencoder auf RTX 3060
- Qwen3-TTS 1.7B auf der RTX 3060 mit Piper als CPU-Fallback
- Whisper.cpp `large-v3-turbo` auf der CPU für lokale deutsche Spracherkennung
- Live-Dashboard mit 21 Tagen Detailhistorie auf Port 8099
@@ -40,6 +41,11 @@ sudo ./install.sh --config /root/mike-ai-install.env
Das Installationsskript baut llama.cpp und die lokalen Images, lädt die
versionierten Modellartefakte und startet ausschließlich den Athena-Kern.
+FLUX.2 Klein 9B ist bei Hugging Face zugriffsbeschränkt. Vor der Installation
+müssen die Bedingungen beider BFL-Repositories akzeptiert und ein Token in der
+unter `HF_TOKEN_FILE` konfigurierten, nur für root lesbaren Datei abgelegt sein.
+Der Token wird ausschließlich als Read-only-Datei in den Download-Container
+eingehängt und weder in `stack.env` noch in Git kopiert.
## Betrieb
@@ -95,6 +101,21 @@ Zwei Slots wurden direkt am Router erfolgreich getestet; Hermes verwaltete zwei
gleichzeitig aktive Chats jedoch nicht zuverlässig. Deshalb bleibt ein Slot der
Standard, bis Hermes' Sitzungsfehler behoben ist.
+### Bildgenerierung mit FLUX.2 Klein 9B FP8 Beta
+
+Ein Bildauftrag verwendet beide GPUs exklusiv. Der Profile Controller stoppt
+zuerst das aktive llama.cpp-Profil und Qwen3-TTS. Anschließend läuft der
+FP8-Transformer auf der RTX 5080 und der in NF4 geladene Qwen3-8B-Textencoder
+auf der RTX 3060. Vor dem VAE-Decoding werden Transformer und Textencoder
+freigegeben. Nach dem Bildauftrag stoppt der Router den Bild-Worker und stellt
+Qwen3-TTS sowie das zuvor aktive Textprofil automatisch wieder her. Piper
+bleibt währenddessen als CPU-Fallback verfügbar.
+
+Die Beta ist derzeit bewusst auf `1024x1024`, vier Schritte, Guidance `1.0`,
+einen parallelen Auftrag und maximal vier lokale Referenzbilder begrenzt.
+Details, Installation, Prüfung und Rollback stehen in
+[docs/FLUX_9B_BETA.md](docs/FLUX_9B_BETA.md).
+
## Endpunkte
- Router: `http://192.168.1.212:8081/v1`
@@ -116,6 +137,11 @@ und bleibt deshalb bei normalen Container-Updates bestehen.
Für Hermes liegt unter `integrations/hermes-qwen3-stream` ein optionales,
persistentes Backend-Plugin. Es nutzt den nativen PCM-Strom und verkürzt den
Beginn der Sprachausgabe, ohne den Modellrouter oder die Textprofile zu ändern.
+Bildgenerierung läuft über `/v1/images/generations`; Hermes verwendet dafür den
+persistenten Benutzer-Provider `athena-local` mit dem Modellnamen
+`FLUX.2-klein-9B-fp8-beta`. Seine versionierte Quelle und Installationshinweise
+liegen unter
+[`integrations/hermes-athena-image`](integrations/hermes-athena-image).
- Portainer: `https://192.168.1.212:9443`
- Hermes-Dashboard auf Unraid: `http://192.168.1.2:9119`
@@ -150,5 +176,6 @@ Der genaue Sicherungsumfang steht in [docs/RECOVERY.md](docs/RECOVERY.md).
- [docs/STANDARD_PROFILE_MATRIX.md](docs/STANDARD_PROFILE_MATRIX.md) – Profile
- [docs/MCP_SERVERS.md](docs/MCP_SERVERS.md) – produktive Werkzeuge
- [docs/RECOVERY.md](docs/RECOVERY.md) – Backup und Neuaufbau
+- [docs/FLUX_9B_BETA.md](docs/FLUX_9B_BETA.md) – 9B-Bildpfad, Test und Rollback
Git enthält keine Secrets, Chatdaten oder Modellgewichte.
diff --git a/compose.yaml b/compose.yaml
index 5757bf4..2f3f787 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -571,6 +571,7 @@ services:
CONTROLLER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
ALLOWED_PROFILES: fast,medium,beta1,large,ultra,uncensored
IMAGE_WORKER: image
+ TTS_WORKER: qwen3
networks: [control]
security_opt: ["no-new-privileges:true"]
healthcheck:
@@ -615,14 +616,13 @@ services:
IMAGE_DIR: /data/images
IMAGE_WORKER_URL: http://image-worker:8086
IMAGE_WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
- IMAGE_MODEL_NAME: FLUX.2-klein-4B
+ IMAGE_MODEL_NAME: FLUX.2-klein-9B-fp8-beta
CHAT_IMAGE_ALLOW_REMOTE_URLS: "false"
ENABLE_IMAGE_GENERATION: "true"
ENABLE_TTS: "true"
- # Stable OpenAI compatibility names remain piper/alloy because an
- # existing Open WebUI database persists those values. The gateway maps
- # alloy to XTTS speaker Annmarie Nele and automatically falls back to
- # Piper if XTTS is unavailable, busy or returns an error.
+ # Stable OpenAI compatibility names remain piper/alloy for existing
+ # clients. The gateway maps them to Qwen3-TTS and falls back to Piper
+ # while Qwen3-TTS is unavailable, busy or paused for image generation.
TTS_WORKER_URL: http://tts-gateway:8085
TTS_MODEL: piper
TTS_VOICES: alloy
@@ -674,13 +674,15 @@ services:
read_only: true
tmpfs: ["/tmp:size=1g,mode=1777"]
volumes:
- - "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}:/models/FLUX.2-klein-4B:ro"
+ - "${FLUX_COMPONENT_DIR:-/data/models/FLUX.2-klein-9B-components}:/models/components:ro"
+ - "${FLUX_TRANSFORMER_DIR:-/data/models/FLUX.2-klein-9B-fp8}:/models/fp8:ro"
- router-images:/data/images
environment:
- NVIDIA_VISIBLE_DEVICES: ${IMAGE_GPU_DEVICES:-1}
+ NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
- FLUX_MODEL_DIR: /models/FLUX.2-klein-4B
+ FLUX_COMPONENT_DIR: /models/components
+ FLUX_TRANSFORMER_FILE: /models/fp8/flux-2-klein-9b-fp8.safetensors
IMAGE_DIR: /data/images
networks: [inference]
security_opt: ["no-new-privileges:true"]
@@ -726,6 +728,8 @@ services:
image: ${QWEN3_TTS_IMAGE:-ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98}
container_name: mike-ai-qwen3-tts
restart: unless-stopped
+ labels:
+ com.mike-ai.tts-worker: qwen3
deploy:
resources:
reservations:
diff --git a/config/install.env.example b/config/install.env.example
index 8c1a3ea..2397581 100644
--- a/config/install.env.example
+++ b/config/install.env.example
@@ -18,7 +18,11 @@ NVIDIA_MIN_DRIVER_MAJOR=570
TEXT_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
SECONDARY_GPU_DEVICES=GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
IMAGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
-FLUX_MODEL_DIR=/data/models/FLUX.2-klein-4B
+# FLUX.2 Klein 9B is gated. Accept both BFL model licenses first, then store
+# the Hugging Face token in this root-readable file (never in this config).
+HF_TOKEN_FILE=/root/.cache/huggingface/token
+FLUX_COMPONENT_DIR=/data/models/FLUX.2-klein-9B-components
+FLUX_TRANSFORMER_DIR=/data/models/FLUX.2-klein-9B-fp8
# Headless remote reachability. Firmware power-loss recovery is configured
# separately once at the physical machine.
diff --git a/dev/test_profile_controller.py b/dev/test_profile_controller.py
index 421afb0..f8592ba 100644
--- a/dev/test_profile_controller.py
+++ b/dev/test_profile_controller.py
@@ -26,6 +26,11 @@ def image_item(state="exited"):
"Labels": {controller.IMAGE_LABEL_KEY: controller.IMAGE_WORKER}}
+def tts_item(state="running"):
+ return {"Id": "id-tts", "State": state,
+ "Labels": {controller.TTS_LABEL_KEY: controller.TTS_WORKER}}
+
+
class ProfileControllerTests(unittest.TestCase):
def test_rejects_unknown_profile_before_docker_call(self):
with patch.object(controller, "docker_request") as request:
@@ -44,6 +49,7 @@ class ProfileControllerTests(unittest.TestCase):
with patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "image_container", return_value=image_item()), \
+ patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request):
result = controller.activate("medium")
@@ -56,7 +62,8 @@ class ProfileControllerTests(unittest.TestCase):
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), \
- patch.object(controller, "image_container", return_value=image_item()):
+ patch.object(controller, "image_container", return_value=image_item()), \
+ patch.object(controller, "tts_container", return_value=tts_item()):
with self.assertRaisesRegex(RuntimeError, "missing"):
controller.activate("fast")
@@ -71,10 +78,12 @@ class ProfileControllerTests(unittest.TestCase):
with patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "image_container", return_value=image_item()), \
+ patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request):
controller.set_image_worker(True)
self.assertEqual(calls, [
("POST", "/containers/id-medium/stop?t=120"),
+ ("POST", "/containers/id-tts/stop?t=30"),
("POST", "/containers/id-flux/start"),
])
@@ -89,6 +98,7 @@ class ProfileControllerTests(unittest.TestCase):
with patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "image_container",
return_value=image_item("running")), \
+ patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request):
controller.activate("fast")
self.assertEqual(calls, [
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 6fdba27..cbf0ff7 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -6,8 +6,9 @@ flowchart LR
H -->|OpenAI API| R[Profile Router
Athena :8081]
R --> P[Profile Controller]
P --> Q[genau ein llama.cpp-Profil
Qwen Fast / Medium / Large / Ultra / Uncensored]
- R --> I[FLUX.2-klein-4B
RTX 5080, Text + Editing]
- R --> T[XTTS RTX 3060
Piper CPU-Fallback]
+ R --> I[FLUX.2 Klein 9B FP8 Beta
RTX 5080 Transformer]
+ I --> E[Qwen3-8B NF4 Textencoder
RTX 3060 während Bildauftrag]
+ R --> T[Qwen3-TTS RTX 3060
Piper CPU-Fallback]
R --> STT[Whisper.cpp large-v3-turbo
CPU, lokale Spracherkennung]
H --> U[MUA / Unraid MCP]
@@ -46,9 +47,29 @@ Kontextgröße:
| Medium | 160.000 Token |
| Large | 192.000 Token |
| Ultra | 262.144 Token |
+| Beta 1 | 192.000 Token |
| Uncensored | 80.000 Token |
+## Exklusiver Bildmodus
+
+Text- und Bildinferenz teilen sich dieselben GPUs und laufen deshalb nicht
+gleichzeitig. Der Wechsel ist transaktional:
+
+1. Router merkt sich das aktive Textprofil.
+2. Profile Controller stoppt alle llama.cpp-Profile und Qwen3-TTS.
+3. Bild-Worker lädt Qwen3-8B als NF4-Textencoder auf die RTX 3060 und den
+ FLUX.2-Klein-9B-FP8-Transformer auf die RTX 5080.
+4. Nach dem Prompt-Encoding werden die Embeddings zur RTX 5080 übertragen.
+5. Vor dem VAE-Decoding werden Textencoder und Transformer freigegeben.
+6. Der Worker wird gestoppt; anschließend starten Qwen3-TTS und das vorherige
+ Textprofil wieder. Piper bleibt als CPU-Fallback verfügbar.
+
+Der Bild-Worker ist lazy und besitzt `restart: "no"`; im normalen Textbetrieb
+belegt er daher keinen VRAM. Container werden über eindeutige Docker-Labels
+gefunden, nicht über zufällige Container-IDs.
+
Die visuelle Fassung liegt als `athena-architecture-map.png` neben dieser Datei.
Eine zweite Detailkarte, `athena-gpu-allocation-map.png`, zeigt die
-profilabhängige Layer-Verteilung auf RTX 5080 und RTX 3060 sowie die festen
-GPU-Zuordnungen von FLUX.2, Vision-Projektor und XTTS.
+profilabhängige Layer-Verteilung auf RTX 5080 und RTX 3060. Die PNG-Karten
+zeigen noch den Stand vor dem 9B-Bildpfad; die aktuelle textuelle Beschreibung
+in diesem Dokument ist verbindlich.
diff --git a/docs/CURRENT_RUNTIME_NOTES.md b/docs/CURRENT_RUNTIME_NOTES.md
index 2bd1b2c..123a7fa 100644
--- a/docs/CURRENT_RUNTIME_NOTES.md
+++ b/docs/CURRENT_RUNTIME_NOTES.md
@@ -1,6 +1,29 @@
# Aktueller produktiver Laufzustand
-Stand: 3. September 2026
+Stand: 7. September 2026
+
+## FLUX.2 Klein 9B FP8 Beta
+
+Die bisherige 4B-Bildinferenz wurde testweise durch FLUX.2 Klein 9B FP8
+ersetzt. Athenas Profile Controller stellt dafür einen exklusiven Zwei-GPU-Pfad
+bereit:
+
+- RTX 5080: 9B-FP8-Diffusionstransformer und VAE-Decoding
+- RTX 3060: Qwen3-8B-Textencoder in NF4
+- Qwen3-TTS und aktives llama.cpp-Profil werden für den Bildauftrag pausiert
+- Piper bleibt währenddessen als CPU-TTS verfügbar
+- nach Abschluss werden TTS und das vorherige Textprofil wiederhergestellt
+
+Ein vollständiger Aufruf über Athenas OpenAI-kompatiblen Router wurde mit
+HTTP 200, einem korrekt gespeicherten 1024×1024-PNG und anschließender
+Wiederherstellung von Qwen3-TTS und `qwen-fast` erfolgreich geprüft. Ein
+isolierter Vergleich ergab ungefähr 14,6 Sekunden Bildlaufzeit mit dem
+GPU-Textencoder gegenüber 102,1 Sekunden mit CPU-Textencoder. Diese Werte sind
+eine lokale Einzelmessung und keine allgemeine Modellgarantie.
+
+Das Modell ist nicht kommerziell lizenziert. Die Bedingungen der beiden
+zugriffsbeschränkten Black-Forest-Labs-Repositories müssen vor dem Download
+akzeptiert werden. Details stehen in [FLUX_9B_BETA.md](FLUX_9B_BETA.md).
## Lokale Spracherkennung
diff --git a/docs/FLUX_9B_BETA.md b/docs/FLUX_9B_BETA.md
new file mode 100644
index 0000000..bdfbcf2
--- /dev/null
+++ b/docs/FLUX_9B_BETA.md
@@ -0,0 +1,153 @@
+# FLUX.2 Klein 9B FP8 Beta auf Athena
+
+Stand: 7. September 2026
+
+## Zweck und Status
+
+Der Bildpfad ersetzt testweise FLUX.2 Klein 4B durch das größere
+FLUX.2-Klein-9B-Modell. Ziel sind bessere Prompttreue, räumliche Beziehungen,
+Objektkonsistenz und Referenzbild-Bearbeitung. Der Pfad ist technisch
+funktionsfähig, bleibt aber bis zu weiteren Qualitäts- und Editing-Tests als
+Beta bezeichnet.
+
+Der OpenAI-kompatible Modellname lautet:
+
+```text
+FLUX.2-klein-9B-fp8-beta
+```
+
+## Modellartefakte und Lizenz
+
+Verwendet werden zwei gepinnte, zugriffsbeschränkte Hugging-Face-Repositories:
+
+| Zweck | Repository | Revision | Lokaler Pfad |
+|---|---|---|---|
+| Pipeline-Komponenten, Qwen3-Textencoder und VAE | `black-forest-labs/FLUX.2-klein-9B` | `92196c8e11f7b6cf2b7493e037d8c5345c559216` | `/data/models/FLUX.2-klein-9B-components` |
+| FP8-Transformer | `black-forest-labs/FLUX.2-klein-9b-fp8` | `902d9d510b51533e07729f19211414a3648b77d2` | `/data/models/FLUX.2-klein-9B-fp8` |
+
+FLUX.2 Klein 9B steht unter der FLUX Non-Commercial License. Vor dem Download
+müssen die Bedingungen beider Repositories im verwendeten Hugging-Face-Konto
+akzeptiert werden. Ein Token gehört ausschließlich in die durch
+`HF_TOKEN_FILE` angegebene, für root lesbare Datei; niemals in Git oder
+`stack.env`.
+
+## GPU-Aufteilung
+
+| Phase | RTX 5080, 16 GB | RTX 3060, 12 GB |
+|---|---|---|
+| Text-/Sprachbetrieb | aktives Qwen3.8-27B-Profil | Qwen3-TTS; Vision je nach Profil |
+| Prompt-Encoding | FLUX-Transformer und VAE | Qwen3-8B-Textencoder, NF4 |
+| Denoising | FLUX-Transformer | Textencoder wird nicht mehr benötigt |
+| VAE-Decoding | VAE; Transformer zuvor freigegeben | Textencoder zuvor freigegeben |
+
+Der Profile Controller stoppt vor dem Start des Bild-Workers alle
+llama.cpp-Profile und den mit `com.mike-ai.tts-worker=qwen3` markierten
+Qwen3-TTS-Container. Dadurch bleibt genügend VRAM für beide Bildkomponenten.
+Nach dem Bildauftrag startet er Qwen3-TTS und das zuvor aktive Textprofil
+wieder. Der TTS-Gateway kann während des Wechsels auf Piper ausweichen.
+
+## Aktuelle Grenzen
+
+- genau 1024 × 1024 Pixel
+- genau vier Inferenzschritte
+- Guidance Scale 1,0
+- ein Bildauftrag gleichzeitig
+- höchstens vier bereits lokal gespeicherte Referenzbilder
+- Textencoder-Maximum 128 Token
+- Bildbearbeitung wird vom Worker angenommen, ist aber noch gesondert
+ Ende-zu-Ende zu qualifizieren
+
+## Installation und Aktualisierung
+
+In `/root/mike-ai-install.env` müssen diese Werte gesetzt sein:
+
+```bash
+HF_TOKEN_FILE=/root/.cache/huggingface/token
+FLUX_COMPONENT_DIR=/data/models/FLUX.2-klein-9B-components
+FLUX_TRANSFORMER_DIR=/data/models/FLUX.2-klein-9B-fp8
+```
+
+Anschließend lädt der normale Installer nur die benötigten Komponenten und die
+gepinnten FP8-Gewichte. Bestehende, vollständige Dateien werden nicht erneut
+geladen:
+
+```bash
+cd /opt/mike-ai/stack
+sudo ./install.sh --config /root/mike-ai-install.env
+```
+
+## Funktionsprobe
+
+Der Router ist nur über das private Netz erreichbar. Ein minimaler Test lautet:
+
+```bash
+curl -fsS http://192.168.1.212:8081/v1/images/generations \
+ -H "Authorization: Bearer $ROUTER_API_KEY" \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "model":"FLUX.2-klein-9B-fp8-beta",
+ "prompt":"A yellow toy excavator on the left and a red toy truck on the right, studio photo",
+ "size":"1024x1024",
+ "steps":4,
+ "guidance":1.0,
+ "seed":9072026
+ }'
+```
+
+Danach müssen folgende Zustände wiederhergestellt sein:
+
+```bash
+docker ps --format '{{.Names}} {{.Status}}' \
+ --filter name=mike-ai-router \
+ --filter name=mike-ai-qwen3-tts \
+ --filter name=mike-ai-llama
+docker ps -a --filter name=mike-ai-image-worker \
+ --format '{{.Names}} {{.Status}}'
+nvidia-smi
+```
+
+Erwartet werden ein gesunder Router, gesundes Qwen3-TTS, genau ein gesundes
+llama.cpp-Profil und ein mit Exit-Code 0 beendeter Bild-Worker.
+
+## Hermes
+
+Hermes auf Unraid verwendet einen persistenten Benutzer-Provider
+`athena-local`. Seine Konfiguration muss auf denselben Modellnamen zeigen:
+
+```yaml
+image_gen:
+ provider: athena-local
+ model: FLUX.2-klein-9B-fp8-beta
+ max_parallel_requests: 1
+```
+
+Der Provider lebt in Hermes-Appdata und bleibt bei normalen Container-Updates
+erhalten. Er gehört nicht in die Desktop-App und muss auf weiteren Clients
+nicht erneut installiert werden. Die versionierte Quellfassung liegt unter
+[`integrations/hermes-athena-image`](../integrations/hermes-athena-image).
+
+## Rollback
+
+Vor der Beta-Bereitstellung wurden auf Athena diese Rückfall-Images angelegt:
+
+```text
+mike-ai/image-worker:4b-rollback-20260907
+mike-ai/profile-controller:rollback-20260907
+```
+
+Die zugehörige Deployment-Sicherung liegt auf Athena unter:
+
+```text
+/data/deploy-backups/20260907-flux9b-beta
+```
+
+Die vorherige Hermes-Konfiguration und der alte Provider liegen auf Unraid
+unter:
+
+```text
+/mnt/nvme-storage/appdata/Hermes-Agent/backups/flux9b-beta-20260907
+```
+
+Ein Rollback darf nicht blind erfolgen: Zuerst aktives Profil, laufende
+Anfragen und vorhandene Image-Tags prüfen, dann nur Image-Worker,
+Profile Controller und Hermes-Provider auf den gesicherten Stand zurücksetzen.
diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md
index 4dea825..75101d3 100644
--- a/docs/RECOVERY.md
+++ b/docs/RECOVERY.md
@@ -15,6 +15,12 @@ Sie bleiben auf der Daten-SSD oder werden anhand der gepinnten Angaben in
`config/install.env.example` erneut geladen. Die Dashboard-Historie liegt
dauerhaft unter `/data/llama-dashboard`.
+Für FLUX.2 Klein 9B müssen vor einem erneuten Download die Bedingungen der
+beiden Black-Forest-Labs-Repositories im Hugging-Face-Konto akzeptiert sein.
+Außerdem muss die in `HF_TOKEN_FILE` angegebene Token-Datei wiederhergestellt
+oder neu erzeugt werden. Der Token selbst ist absichtlich nicht Bestandteil
+des Git-Repositories oder des Athena-Backups.
+
Portainers lokale Konfiguration liegt im Docker-Volume `portainer_data` und
wird zusammen mit den übrigen nicht reproduzierbaren Volumes gesichert und
wiederhergestellt.
diff --git a/install.sh b/install.sh
index 7aab107..08da825 100755
--- a/install.sh
+++ b/install.sh
@@ -365,7 +365,8 @@ UNCENSORED_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDAR
UNCENSORED_TENSOR_SPLIT=${UNCENSORED_TENSOR_SPLIT:-90,10}
UNCENSORED_MTP_MAX=${UNCENSORED_MTP_MAX:-2}
IMAGE_GPU_DEVICES=${IMAGE_GPU_DEVICES:-${TEXT_GPU_DEVICES:-0}}
-FLUX_MODEL_DIR=${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}
+FLUX_COMPONENT_DIR=${FLUX_COMPONENT_DIR:-/data/models/FLUX.2-klein-9B-components}
+FLUX_TRANSFORMER_DIR=${FLUX_TRANSFORMER_DIR:-/data/models/FLUX.2-klein-9B-fp8}
LLAMA_THREADS=${LLAMA_THREADS:-6}
LLAMA_THREADS_BATCH=${LLAMA_THREADS_BATCH:-6}
LLAMA_CACHE_RAM_MIB=${LLAMA_CACHE_RAM_MIB:-32768}
@@ -486,14 +487,28 @@ build_and_start() {
docker build --progress=plain --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 image build image-worker
- if [[ ! -s ${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}/model_index.json ]]; then
- log "FLUX.2-klein-4B laden"
- install -d -m 0755 "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}"
- docker run --rm --entrypoint python \
- -v "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}:/download" \
+ local flux_components=${FLUX_COMPONENT_DIR:-/data/models/FLUX.2-klein-9B-components}
+ local flux_transformer=${FLUX_TRANSFORMER_DIR:-/data/models/FLUX.2-klein-9B-fp8}
+ local hf_token_file=${HF_TOKEN_FILE:-/root/.cache/huggingface/token}
+ if [[ ! -s $flux_components/model_index.json || \
+ ! -s $flux_transformer/flux-2-klein-9b-fp8.safetensors ]]; then
+ [[ -r $hf_token_file ]] || die \
+ "Hugging-Face-Token fehlt: $hf_token_file (FLUX.2 Klein 9B ist gated)"
+ log "FLUX.2 Klein 9B Komponenten und FP8-Transformer laden"
+ install -d -m 0755 "$flux_components" "$flux_transformer"
+ docker run --rm --entrypoint /opt/image-venv/bin/python \
+ -e HF_TOKEN_PATH=/run/secrets/hf-token \
+ -v "$hf_token_file:/run/secrets/hf-token:ro" \
+ -v "$flux_components:/download" \
mike-ai/image-worker:local -c \
- "from huggingface_hub import snapshot_download; snapshot_download('black-forest-labs/FLUX.2-klein-4B', revision='e7b7dc27f91deacad38e78976d1f2b499d76a294', local_dir='/download')"
- chmod -R a-w "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}"
+ "from huggingface_hub import snapshot_download; snapshot_download('black-forest-labs/FLUX.2-klein-9B', revision='92196c8e11f7b6cf2b7493e037d8c5345c559216', local_dir='/download', allow_patterns=['model_index.json', 'scheduler/*', 'text_encoder/*', 'tokenizer/*', 'transformer/config.json', 'vae/*'])"
+ docker run --rm --entrypoint /opt/image-venv/bin/python \
+ -e HF_TOKEN_PATH=/run/secrets/hf-token \
+ -v "$hf_token_file:/run/secrets/hf-token:ro" \
+ -v "$flux_transformer:/download" \
+ mike-ai/image-worker:local -c \
+ "from huggingface_hub import snapshot_download; snapshot_download('black-forest-labs/FLUX.2-klein-9b-fp8', revision='902d9d510b51533e07729f19211414a3648b77d2', local_dir='/download', allow_patterns=['flux-2-klein-9b-fp8.safetensors', 'README.md', 'LICENSE.md'])"
+ chmod -R a-w "$flux_components" "$flux_transformer"
fi
# Creates the tools network and deploys the only host-bound MCP: Operator.
# Portable MCPs and Hermes live on Unraid and are restored through Appdata.
diff --git a/integrations/hermes-athena-image/README.md b/integrations/hermes-athena-image/README.md
new file mode 100644
index 0000000..aed406b
--- /dev/null
+++ b/integrations/hermes-athena-image/README.md
@@ -0,0 +1,46 @@
+# Hermes Athena image provider
+
+Hermes backend plugin for the OpenAI-compatible image API exposed by the
+Athena profile router. The router starts the local FLUX worker on demand,
+unloads the active LLM and Qwen3-TTS, and restores both after generation.
+
+## Gateway installation
+
+Install this directory on the Hermes gateway, not on each Desktop client:
+
+```text
+$HERMES_HOME/plugins/image_gen/athena-local/
+ __init__.py
+ plugin.yaml
+```
+
+Set these secrets or environment variables on the gateway:
+
+```text
+ATHENA_IMAGE_BASE_URL=http://192.168.1.212:8081/v1
+ATHENA_IMAGE_API_KEY=
+```
+
+Then enable and select the provider:
+
+```yaml
+plugins:
+ enabled:
+ - image_gen/athena-local
+
+image_gen:
+ provider: athena-local
+ model: FLUX.2-klein-9B-fp8-beta
+ max_parallel_requests: 1
+```
+
+Restart the Hermes gateway after changing plugin files or configuration. A
+second computer connected to the same gateway needs no plugin installation.
+
+Optional overrides:
+
+- `ATHENA_IMAGE_MODEL` defaults to `FLUX.2-klein-9B-fp8-beta`.
+- `ROUTER_API_KEY` is accepted as a migration fallback.
+- An existing `HERMES_CUSTOM_192_168_1_212_8081_API_KEY` is accepted as the
+ final fallback, so an existing Athena chat-provider setup needs no duplicate
+ secret.
diff --git a/integrations/hermes-athena-image/__init__.py b/integrations/hermes-athena-image/__init__.py
new file mode 100644
index 0000000..54d9415
--- /dev/null
+++ b/integrations/hermes-athena-image/__init__.py
@@ -0,0 +1,260 @@
+"""Hermes image generation/edit provider for the local Athena router.
+
+The provider deliberately rejects public destinations. Prompts and generated
+images may only travel to a loopback or private-network address.
+"""
+
+from __future__ import annotations
+
+import base64
+import ipaddress
+import json
+import os
+import urllib.error
+import urllib.parse
+import urllib.request
+from typing import Any, Dict, List, Optional
+
+from agent.image_gen_provider import (
+ DEFAULT_ASPECT_RATIO,
+ ImageGenProvider,
+ error_response,
+ normalize_reference_images,
+ resolve_aspect_ratio,
+ save_b64_image,
+ success_response,
+)
+from agent.secret_scope import get_secret
+
+
+_SIZES = {
+ "landscape": "1536x1024",
+ "square": "1024x1024",
+ "portrait": "1024x1536",
+}
+_DEFAULT_BASE_URL = "http://192.168.1.212:8081/v1"
+_DEFAULT_MODEL = "FLUX.2-klein-9B-fp8-beta"
+_MAX_IMAGE_BYTES = 20 * 1024 * 1024
+
+
+def _base_url() -> str:
+ """Use the dedicated image URL and never inherit an unrelated chat URL."""
+ override = os.environ.get("ATHENA_IMAGE_BASE_URL", "").strip()
+ return (override or _DEFAULT_BASE_URL).rstrip("/")
+
+
+def _model() -> str:
+ return os.environ.get("ATHENA_IMAGE_MODEL", "").strip() or _DEFAULT_MODEL
+
+
+def _api_key() -> str:
+ """Prefer a scoped key; accept the existing router key for migration."""
+ return (
+ get_secret("ATHENA_IMAGE_API_KEY", "")
+ or get_secret("ROUTER_API_KEY", "")
+ or get_secret("HERMES_CUSTOM_192_168_1_212_8081_API_KEY", "")
+ or ""
+ ).strip()
+
+
+def _private_destination(url: str) -> bool:
+ """Fail closed unless the configured endpoint is local/private."""
+ try:
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
+ return False
+ if parsed.hostname == "localhost":
+ return True
+ address = ipaddress.ip_address(parsed.hostname)
+ return address.is_private or address.is_loopback
+ except (ValueError, TypeError):
+ return False
+
+
+def _load_private_image(ref: str) -> bytes:
+ """Load a local/data/private-LAN image without contacting public hosts."""
+ ref = ref.strip()
+ lower = ref.lower()
+ if lower.startswith("data:image/"):
+ _, separator, payload = ref.partition(",")
+ if not separator:
+ raise ValueError("invalid image data URI")
+ data = base64.b64decode(payload, validate=True)
+ elif lower.startswith(("http://", "https://")):
+ if not _private_destination(ref):
+ raise ValueError("public reference-image URLs are blocked")
+ request = urllib.request.Request(
+ ref, headers={"User-Agent": "Hermes-Athena-Image/2.0"})
+ with urllib.request.urlopen(request, timeout=60) as response:
+ data = response.read(_MAX_IMAGE_BYTES + 1)
+ else:
+ from agent.file_safety import raise_if_read_blocked
+ raise_if_read_blocked(ref)
+ with open(ref, "rb") as image_file:
+ data = image_file.read(_MAX_IMAGE_BYTES + 1)
+ if not data or len(data) > _MAX_IMAGE_BYTES:
+ raise ValueError("reference image is empty or exceeds 20 MiB")
+ return data
+
+
+class AthenaLocalImageProvider(ImageGenProvider):
+ @property
+ def name(self) -> str:
+ return "athena-local"
+
+ @property
+ def display_name(self) -> str:
+ return "Athena Local (FLUX.2 Klein)"
+
+ def is_available(self) -> bool:
+ return bool(_api_key()) and _private_destination(_base_url())
+
+ def list_models(self) -> List[Dict[str, Any]]:
+ return [{
+ "id": _model(),
+ "display": "FLUX.2 Klein 9B FP8 Beta on Athena",
+ "speed": "local",
+ "strengths": "Private local generation and multi-reference editing",
+ "price": "local / no cloud",
+ }]
+
+ def default_model(self) -> Optional[str]:
+ return _model()
+
+ def capabilities(self) -> Dict[str, Any]:
+ return {"modalities": ["text", "image"], "max_reference_images": 3}
+
+ def get_setup_schema(self) -> Dict[str, Any]:
+ return {
+ "name": "Athena Local (FLUX.2 Klein)",
+ "badge": "local",
+ "tag": "Private image generation on Athena; public endpoints are rejected",
+ "env_vars": [
+ {"key": "ATHENA_IMAGE_API_KEY", "prompt": "Athena router API key"},
+ {
+ "key": "ATHENA_IMAGE_BASE_URL",
+ "prompt": "Athena image API base URL",
+ "default": _DEFAULT_BASE_URL,
+ },
+ ],
+ }
+
+ def generate(
+ self,
+ prompt: str,
+ aspect_ratio: str = DEFAULT_ASPECT_RATIO,
+ *,
+ image_url: Optional[str] = None,
+ reference_image_urls: Optional[List[str]] = None,
+ **kwargs: Any,
+ ) -> Dict[str, Any]:
+ clean_prompt = (prompt or "").strip()
+ aspect = resolve_aspect_ratio(aspect_ratio)
+ base_url = _base_url()
+
+ if not clean_prompt:
+ return error_response(
+ error="Prompt is required.", error_type="invalid_argument",
+ provider=self.name, aspect_ratio=aspect)
+ if not _private_destination(base_url):
+ return error_response(
+ error=("Athena image endpoint is not a private-network "
+ "destination; request blocked."),
+ error_type="unsafe_destination", provider=self.name,
+ prompt=clean_prompt, aspect_ratio=aspect)
+
+ model = _model()
+ api_key = _api_key()
+ if not api_key:
+ return error_response(
+ error=("No Athena router key is configured. Set "
+ "ATHENA_IMAGE_API_KEY or reuse "
+ "HERMES_CUSTOM_192_168_1_212_8081_API_KEY."),
+ error_type="auth_required", provider=self.name, model=model,
+ prompt=clean_prompt, aspect_ratio=aspect)
+
+ sources: List[str] = []
+ if isinstance(image_url, str) and image_url.strip():
+ sources.append(image_url.strip())
+ sources.extend(normalize_reference_images(reference_image_urls) or [])
+ sources = sources[:4]
+ try:
+ encoded_sources = [
+ base64.b64encode(_load_private_image(source)).decode("ascii")
+ for source in sources
+ ]
+ except Exception as exc:
+ return error_response(
+ error=f"Reference image could not be loaded locally: {exc}",
+ error_type="io_error", provider=self.name, model=model,
+ prompt=clean_prompt, aspect_ratio=aspect)
+
+ request_data = {
+ "model": model,
+ "prompt": clean_prompt,
+ "size": _SIZES[aspect],
+ "n": 1,
+ "quality": "standard",
+ "steps": 4,
+ "guidance": 1.0,
+ "response_format": "b64_json",
+ }
+ endpoint = "generations"
+ if encoded_sources:
+ endpoint = "edits"
+ request_data["image_b64"] = encoded_sources[0]
+ request_data["reference_images_b64"] = encoded_sources[1:]
+ request = urllib.request.Request(
+ f"{base_url}/images/{endpoint}",
+ data=json.dumps(request_data).encode("utf-8"), method="POST",
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ })
+
+ try:
+ with urllib.request.urlopen(request, timeout=900) as response:
+ result = json.load(response)
+ except urllib.error.HTTPError as exc:
+ try:
+ detail = exc.read(4096).decode("utf-8", errors="replace")
+ except Exception:
+ detail = ""
+ return error_response(
+ error=f"Athena image request failed (HTTP {exc.code}): {detail[:500]}",
+ error_type="api_error", provider=self.name, model=model,
+ prompt=clean_prompt, aspect_ratio=aspect)
+ except (OSError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
+ return error_response(
+ error=f"Athena image request failed: {exc}",
+ error_type="connection_error", provider=self.name, model=model,
+ prompt=clean_prompt, aspect_ratio=aspect)
+
+ items = result.get("data") if isinstance(result, dict) else None
+ first = items[0] if isinstance(items, list) and items else None
+ b64_data = first.get("b64_json") if isinstance(first, dict) else None
+ if not isinstance(b64_data, str) or not b64_data:
+ return error_response(
+ error="Athena returned no image data.",
+ error_type="empty_response", provider=self.name, model=model,
+ prompt=clean_prompt, aspect_ratio=aspect)
+
+ try:
+ saved = save_b64_image(b64_data, prefix="athena_flux2")
+ except Exception as exc:
+ return error_response(
+ error=f"Generated image could not be saved: {exc}",
+ error_type="io_error", provider=self.name, model=model,
+ prompt=clean_prompt, aspect_ratio=aspect)
+
+ return success_response(
+ image=str(saved), model=model, prompt=clean_prompt,
+ aspect_ratio=aspect, provider=self.name,
+ modality="image" if encoded_sources else "text",
+ extra={"size": _SIZES[aspect], "local_only": True,
+ "reference_images": len(encoded_sources)})
+
+
+def register(ctx) -> None:
+ ctx.register_image_gen_provider(AthenaLocalImageProvider())
diff --git a/integrations/hermes-athena-image/plugin.yaml b/integrations/hermes-athena-image/plugin.yaml
new file mode 100644
index 0000000..62d22ce
--- /dev/null
+++ b/integrations/hermes-athena-image/plugin.yaml
@@ -0,0 +1,7 @@
+name: athena-local
+version: 2.2.0
+description: "Local-only FLUX.2 Klein 9B FP8 beta generation and editing through Athena."
+author: Michael
+kind: backend
+requires_env:
+ - HERMES_CUSTOM_192_168_1_212_8081_API_KEY
diff --git a/platform/docker/image-worker/Dockerfile b/platform/docker/image-worker/Dockerfile
index 5d36bfd..c220e76 100644
--- a/platform/docker/image-worker/Dockerfile
+++ b/platform/docker/image-worker/Dockerfile
@@ -13,9 +13,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends python3.12-venv
"transformers==${TRANSFORMERS_VERSION}" \
"accelerate==${ACCELERATE_VERSION}" \
"huggingface-hub==${HF_HUB_VERSION}" \
+ "nvidia-modelopt==0.46.0" bitsandbytes \
sentencepiece protobuf safetensors pillow && \
useradd --system --uid 10002 --home /nonexistent --shell /usr/sbin/nologin image-worker
COPY image_worker.py /app/image_worker.py
+COPY image_worker_9b.py /app/image_worker_9b.py
USER 10002:10002
-ENTRYPOINT ["/opt/image-venv/bin/python", "/app/image_worker.py"]
+ENTRYPOINT ["/opt/image-venv/bin/python", "/app/image_worker_9b.py"]
diff --git a/platform/docker/image-worker/image_worker_9b.py b/platform/docker/image-worker/image_worker_9b.py
new file mode 100644
index 0000000..d86cfdd
--- /dev/null
+++ b/platform/docker/image-worker/image_worker_9b.py
@@ -0,0 +1,282 @@
+#!/usr/bin/env python3
+"""Private FLUX.2 Klein 9B FP8 beta worker for Athena's two GPUs.
+
+The FP8 diffusion transformer runs on the RTX 5080. A Qwen3-8B NF4 text
+encoder runs on the RTX 3060 while the profile controller temporarily pauses
+Qwen3-TTS. The transformer and encoder are released before VAE decoding so
+the 1024px decoder has sufficient workspace on the RTX 5080.
+"""
+
+from __future__ import annotations
+
+import gc
+import json
+import os
+import signal
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from types import MethodType
+
+HOST = os.environ.get("WORKER_HOST", "0.0.0.0")
+PORT = int(os.environ.get("WORKER_PORT", "8086"))
+TOKEN = os.environ.get("WORKER_TOKEN", "").strip()
+COMPONENT_DIR = os.environ.get("FLUX_COMPONENT_DIR", "/models/components")
+TRANSFORMER_FILE = os.environ.get(
+ "FLUX_TRANSFORMER_FILE", "/models/fp8/flux-2-klein-9b-fp8.safetensors")
+OUTPUT_DIR = Path(os.environ.get("IMAGE_DIR", "/data/images")).resolve()
+ACTIVE = False
+
+os.environ.setdefault("DIFFUSERS_VERBOSITY", "error")
+os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
+os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
+os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
+
+if len(TOKEN) < 32:
+ raise RuntimeError("WORKER_TOKEN is missing or too short")
+
+signal.signal(signal.SIGTERM, lambda *_: os._exit(0))
+
+
+def _devices(torch):
+ if torch.cuda.device_count() != 2:
+ raise RuntimeError("FLUX 9B beta requires exactly two visible CUDA GPUs")
+ totals = {i: torch.cuda.get_device_properties(i).total_memory
+ for i in range(torch.cuda.device_count())}
+ transformer_index = max(totals, key=totals.get)
+ encoder_index = min(totals, key=totals.get)
+ return (transformer_index, encoder_index,
+ torch.device(f"cuda:{transformer_index}"),
+ torch.device(f"cuda:{encoder_index}"))
+
+
+def _install_fp8_converter():
+ import diffusers.loaders.single_file_model as single_file_model
+
+ original = single_file_model.SINGLE_FILE_LOADABLE_CLASSES[
+ "Flux2Transformer2DModel"]["checkpoint_mapping_fn"]
+ scales = {}
+ double_map = {
+ "img_attn.proj": "attn.to_out.0",
+ "img_mlp.0": "ff.linear_in",
+ "img_mlp.2": "ff.linear_out",
+ "txt_attn.proj": "attn.to_add_out",
+ "txt_mlp.0": "ff_context.linear_in",
+ "txt_mlp.2": "ff_context.linear_out",
+ }
+ single_map = {
+ "linear1": "attn.to_qkv_mlp_proj",
+ "linear2": "attn.to_out",
+ }
+
+ def record(key, value):
+ parts = key.split(".")
+ scale_name, block = parts[-1], parts[1]
+ within = ".".join(parts[2:-1])
+ if parts[0] == "double_blocks":
+ if within == "img_attn.qkv":
+ targets = ("attn.to_q", "attn.to_k", "attn.to_v")
+ elif within == "txt_attn.qkv":
+ targets = ("attn.add_q_proj", "attn.add_k_proj",
+ "attn.add_v_proj")
+ else:
+ targets = (double_map[within],)
+ prefix = f"transformer_blocks.{block}"
+ elif parts[0] == "single_blocks":
+ targets = (single_map[within],)
+ prefix = f"single_transformer_blocks.{block}"
+ else:
+ raise ValueError(f"unexpected FP8 scale key: {key}")
+ for target in targets:
+ scales.setdefault(f"{prefix}.{target}", {})[scale_name] = value.clone()
+
+ def convert(checkpoint, **kwargs):
+ scales.clear()
+ for key in list(checkpoint):
+ if key.endswith((".input_scale", ".weight_scale")):
+ record(key, checkpoint.pop(key))
+ return original(checkpoint=checkpoint, **kwargs)
+
+ single_file_model.SINGLE_FILE_LOADABLE_CLASSES[
+ "Flux2Transformer2DModel"]["checkpoint_mapping_fn"] = convert
+ return scales
+
+
+def _fp8_forward(torch, module, inputs):
+ shape = inputs.shape
+ input_fp8 = ((inputs / module._fp8_input_scale)
+ .clamp(torch.finfo(torch.float8_e4m3fn).min,
+ torch.finfo(torch.float8_e4m3fn).max)
+ .to(torch.float8_e4m3fn).reshape(-1, shape[-1]))
+ output = torch._scaled_mm(
+ input_fp8,
+ module.weight.reshape(-1, module.weight.shape[-1]).t(),
+ scale_a=module._fp8_input_scale,
+ scale_b=module._fp8_weight_scale,
+ bias=module.bias,
+ out_dtype=inputs.dtype,
+ use_fast_accum=True,
+ )
+ return output.reshape(*shape[:-1], output.shape[-1])
+
+
+def generate(data: dict) -> dict:
+ global ACTIVE
+ import torch
+ from diffusers import (Flux2KleinPipeline, Flux2Transformer2DModel,
+ NVIDIAModelOptConfig)
+ from modelopt.torch.opt import enable_huggingface_checkpointing
+ from modelopt.torch.quantization.config import FP8_DEFAULT_CFG
+ from PIL import Image
+ from transformers import BitsAndBytesConfig, Qwen3ForCausalLM
+
+ prompt, filename = data.get("prompt"), data.get("filename")
+ if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 8000:
+ raise ValueError("invalid prompt")
+ if (not isinstance(filename, str) or Path(filename).name != filename
+ or not filename.endswith(".png")):
+ raise ValueError("invalid filename")
+ width, height = int(data.get("width", 1024)), int(data.get("height", 1024))
+ if (width, height) != (1024, 1024):
+ raise ValueError("FLUX 9B beta currently supports only 1024x1024")
+ if int(data.get("steps", 4)) != 4 or float(data.get("guidance", 1.0)) != 1.0:
+ raise ValueError("FLUX 9B beta requires steps=4 and guidance=1.0")
+
+ source_files = data.get("source_files") or []
+ if not isinstance(source_files, list) or len(source_files) > 4:
+ raise ValueError("invalid source image list")
+ source_images = []
+ for name in source_files:
+ if not isinstance(name, str) or Path(name).name != name:
+ raise ValueError("invalid source image filename")
+ source = (OUTPUT_DIR / name).resolve()
+ if source.parent != OUTPUT_DIR or not source.is_file():
+ raise ValueError("source image not found")
+ with Image.open(source) as opened:
+ source_images.append(opened.convert("RGB"))
+
+ started = time.monotonic()
+ ACTIVE = True
+ transformer = text_encoder = pipe = latent = decoded = image = None
+ try:
+ enable_huggingface_checkpointing()
+ scales = _install_fp8_converter()
+ tx_index, enc_index, tx_device, enc_device = _devices(torch)
+ quantization = NVIDIAModelOptConfig(
+ quant_type="FP8", weight_only=False,
+ modelopt_config=FP8_DEFAULT_CFG)
+ transformer = Flux2Transformer2DModel.from_single_file(
+ TRANSFORMER_FILE, config=COMPONENT_DIR, subfolder="transformer",
+ quantization_config=quantization, torch_dtype=torch.bfloat16,
+ device_map={"": tx_index}, local_files_only=True)
+ patched = 0
+ for module_name, module in transformer.named_modules():
+ if module_name not in scales:
+ continue
+ module.register_buffer("_fp8_input_scale",
+ scales[module_name]["input_scale"])
+ module.register_buffer("_fp8_weight_scale",
+ scales[module_name]["weight_scale"])
+ module.forward = MethodType(
+ lambda self, inputs: _fp8_forward(torch, self, inputs), module)
+ patched += 1
+ if patched != len(scales):
+ raise RuntimeError(f"patched only {patched} of {len(scales)} FP8 layers")
+ transformer.to(tx_device)
+
+ text_encoder = Qwen3ForCausalLM.from_pretrained(
+ os.path.join(COMPONENT_DIR, "text_encoder"),
+ torch_dtype=torch.bfloat16, low_cpu_mem_usage=True,
+ quantization_config=BitsAndBytesConfig(
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
+ bnb_4bit_compute_dtype=torch.bfloat16,
+ bnb_4bit_use_double_quant=True),
+ device_map={"": enc_index}, local_files_only=True)
+ pipe = Flux2KleinPipeline.from_pretrained(
+ COMPONENT_DIR, transformer=transformer, text_encoder=text_encoder,
+ torch_dtype=torch.bfloat16, local_files_only=True)
+ pipe.vae.enable_slicing()
+ pipe.vae.enable_tiling()
+ pipe.vae.to(tx_device)
+ loaded = time.monotonic() - started
+
+ prompt_embeds, _ = pipe.encode_prompt(
+ prompt.strip(), device=enc_device, max_sequence_length=128)
+ prompt_embeds = prompt_embeds.to(tx_device)
+ pipe.text_encoder = None
+ seed = data.get("seed")
+ generator = None if seed is None else torch.Generator(
+ device=tx_device).manual_seed(int(seed))
+ kwargs = {
+ "prompt": None, "prompt_embeds": prompt_embeds,
+ "height": height, "width": width, "num_inference_steps": 4,
+ "guidance_scale": 1.0, "generator": generator,
+ "output_type": "latent",
+ }
+ if source_images:
+ kwargs["image"] = (source_images[0] if len(source_images) == 1
+ else source_images)
+ latent = pipe(**kwargs).images
+
+ pipe.transformer = None
+ del transformer, text_encoder, prompt_embeds, generator
+ transformer = text_encoder = None
+ gc.collect()
+ torch.cuda.empty_cache()
+ latent = latent.to(device=tx_device, dtype=pipe.vae.dtype)
+ decoded = pipe.vae.decode(latent, return_dict=False)[0]
+ image = pipe.image_processor.postprocess(
+ decoded.detach(), output_type="pil")[0]
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ image.save(OUTPUT_DIR / filename)
+ return {"status": "ok", "filename": filename,
+ "seconds": round(time.monotonic() - started, 3),
+ "load_seconds": round(loaded, 3),
+ "model": "FLUX.2-klein-9B-fp8-beta"}
+ finally:
+ for value in (image, decoded, latent, pipe, text_encoder, transformer):
+ if value is not None:
+ del value
+ gc.collect()
+ torch.cuda.empty_cache()
+ ACTIVE = False
+
+
+class Handler(BaseHTTPRequestHandler):
+ def log_message(self, fmt: str, *args: object) -> None:
+ print(f"[flux9b-beta] {self.client_address[0]} {fmt % args}", flush=True)
+
+ 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 do_GET(self) -> None: # noqa: N802
+ if self.path == "/health":
+ self.reply(200, {"status": "ok", "model_loaded": ACTIVE,
+ "model": "FLUX.2-klein-9B-fp8-beta"})
+ else:
+ self.reply(404, {"error": "not found"})
+
+ def do_POST(self) -> None: # noqa: N802
+ if self.headers.get("Authorization", "") != f"Bearer {TOKEN}":
+ self.reply(401, {"error": "unauthorized"})
+ return
+ if self.path != "/generate":
+ self.reply(404, {"error": "not found"})
+ return
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ if length < 2 or length > 16384:
+ raise ValueError("invalid request size")
+ self.reply(200, generate(json.loads(self.rfile.read(length))))
+ except Exception as exc:
+ print(f"[flux9b-beta] generation failed: {type(exc).__name__}: "
+ f"{str(exc)[:1000]}", flush=True)
+ self.reply(500, {"status": "error", "message": str(exc)})
+
+
+ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
diff --git a/platform/docker/profile-controller/profile_controller.py b/platform/docker/profile-controller/profile_controller.py
index 2a4efab..7fa8e46 100644
--- a/platform/docker/profile-controller/profile_controller.py
+++ b/platform/docker/profile-controller/profile_controller.py
@@ -25,6 +25,8 @@ ALLOWED = tuple(x.strip() for x in os.environ.get(
LABEL_KEY = "com.mike-ai.llama-profile"
IMAGE_LABEL_KEY = "com.mike-ai.image-worker"
IMAGE_WORKER = os.environ.get("IMAGE_WORKER", "image")
+TTS_LABEL_KEY = "com.mike-ai.tts-worker"
+TTS_WORKER = os.environ.get("TTS_WORKER", "qwen3")
LOCK = threading.Lock()
log = logging.getLogger("profile-controller")
@@ -77,6 +79,15 @@ def image_container() -> dict:
return matches[0]
+def tts_container() -> dict:
+ matches = [item for item in labelled_containers(TTS_LABEL_KEY)
+ if item.get("Labels", {}).get(TTS_LABEL_KEY) == TTS_WORKER]
+ if len(matches) != 1:
+ raise RuntimeError(
+ f"expected exactly one TTS worker {TTS_WORKER!r}, found {len(matches)}")
+ return matches[0]
+
+
def stop_container(item: dict, timeout: int = 120) -> None:
if item.get("State") != "running":
return
@@ -85,6 +96,14 @@ def stop_container(item: dict, timeout: int = 120) -> None:
raise RuntimeError(f"failed to stop container: HTTP {status}")
+def start_container(item: dict) -> None:
+ if item.get("State") == "running":
+ return
+ status, _ = docker_request("POST", f"/containers/{item['Id']}/start")
+ if status not in (204, 304):
+ raise RuntimeError(f"failed to start container: HTTP {status}")
+
+
def stop_inference() -> dict:
with LOCK:
items = containers()
@@ -101,14 +120,17 @@ def set_image_worker(running: bool) -> dict:
# The image worker may never overlap a llama profile on the 5080.
for profile_item in containers().values():
stop_container(profile_item)
- if item.get("State") != "running":
- status, _ = docker_request("POST", f"/containers/{item['Id']}/start")
- if status not in (204, 304):
- raise RuntimeError(f"failed to start image worker: HTTP {status}")
+ # The 9B beta text encoder temporarily borrows the RTX 3060 from
+ # Qwen3-TTS. The gateway retains Piper as a fallback meanwhile.
+ stop_container(tts_container(), timeout=30)
+ start_container(item)
else:
# CUDA/PyTorch may not react promptly to SIGTERM after an OOM.
# Bound recovery time and let Docker issue SIGKILL afterwards.
stop_container(item, timeout=20)
+ # TTS is restored by the following profile activation. Keeping it
+ # stopped here lets the router verify that both GPUs really
+ # released the image model before Qwen and TTS are reloaded.
return {"image_worker": "running" if running else "stopped"}
@@ -126,6 +148,7 @@ def activate(profile: str) -> dict:
with LOCK:
# Defensive mutual exclusion even if a caller bypasses the router.
stop_container(image_container())
+ start_container(tts_container())
items = containers()
missing = [name for name in ALLOWED if name not in items]
if missing:
diff --git a/platform/models/manifest.example.yaml b/platform/models/manifest.example.yaml
index 0b62217..d1bf7fa 100644
--- a/platform/models/manifest.example.yaml
+++ b/platform/models/manifest.example.yaml
@@ -28,12 +28,17 @@ models:
sha256: "REPLACE_AFTER_VERIFICATION"
image:
role: image-generation
- source: black-forest-labs/FLUX.2-klein-4B
- revision: e7b7dc27f91deacad38e78976d1f2b499d76a294
- target: /data/models/FLUX.2-klein-4B
- revision: "f332072aa78be7aecdf3ee76d5c247082da564a6"
- xtts:
+ source: black-forest-labs/FLUX.2-klein-9B
+ revision: 92196c8e11f7b6cf2b7493e037d8c5345c559216
+ target: /data/models/FLUX.2-klein-9B-components
+ include: model_index.json,scheduler/*,text_encoder/*,tokenizer/*,transformer/config.json,vae/*
+ image_transformer:
+ role: image-generation-fp8-transformer
+ source: black-forest-labs/FLUX.2-klein-9b-fp8
+ revision: 902d9d510b51533e07729f19211414a3648b77d2
+ file: flux-2-klein-9b-fp8.safetensors
+ target: /data/models/FLUX.2-klein-9B-fp8
+ qwen3_tts:
role: text-to-speech
- source: coqui/XTTS-v2
- target: /opt/mike-ai/xtts/.cache
- revision: "PIN_EXACT_REVISION"
+ source: Qwen/Qwen3-TTS-12Hz-1.7B-Base
+ target: /data/models/qwen3-tts-cache
diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py
index fdcb9af..2593a11 100755
--- a/router/ai_profile_router.py
+++ b/router/ai_profile_router.py
@@ -20,13 +20,13 @@ Kommandos: POST /fast, /medium, /beta1, /large, /ultra,
/uncensored
GET /status (Zustand)
-Bildgenerierung und Editing (FLUX.2-klein-4B):
+Bildgenerierung und Editing (FLUX.2 Klein 9B FP8 beta):
POST /v1/images/generations (OpenAI-kompatibel)
POST /v1/images/edits (lokal, Referenzbilder)
GET /images (Liste)
GET /images/ (PNG-Download)
-Sprachausgabe (XTTS-v2, multilingual, CPU-only):
+Sprachausgabe (Qwen3-TTS auf RTX 3060, Piper als CPU-Fallback):
POST /v1/audio/speech (OpenAI-kompatibel)
GET /v1/audio/voices (verfügbare Stimmen)
@@ -40,7 +40,8 @@ Der Router leitet /v1/audio/speech und /v1/audio/transcriptions
per HTTP an die Worker weiter.
Der Router agiert als Modell-Orchestrator: vor der Generierung wird
-llama.cpp gestoppt, der Bild-Worker lädt FLUX.2, generiert/bearbeitet und entlädt
+llama.cpp und Qwen3-TTS gestoppt, der Bild-Worker lädt FLUX.2 und den
+Text-Encoder auf getrennte GPUs, generiert/bearbeitet und entlädt
das Modell wieder; danach wird das vorherige Qwen-Profil wiederher-
gestellt und erst dann geantwortet (try/finally – Qwen wird auch bei
Fehlgeschlagener Generierung wiederhergestellt).
@@ -126,7 +127,7 @@ DEFAULT_REASONING_EFFORT = os.environ.get(
GLOBAL_SYSTEM_POLICY_FILE = os.environ.get(
"GLOBAL_SYSTEM_POLICY_FILE", "").strip()
-# --- Bildgenerierung und Referenzbild-Bearbeitung (FLUX.2 Klein 4B) ---
+# --- Bildgenerierung und Referenzbild-Bearbeitung (FLUX.2 Klein 9B FP8) ---
LLAMA_SERVICE = os.environ.get("LLAMA_SERVICE", "mike-ai-llama-ui.service")
SYSTEMCTL_BIN = os.environ.get("SYSTEMCTL_BIN", "systemctl")
IMAGE_WORKER = os.environ.get(
@@ -135,7 +136,8 @@ IMAGE_PYTHON = os.environ.get(
"IMAGE_PYTHON", "/opt/mike-ai/ai-profile-router/venv/bin/python")
IMAGE_WORKER_URL = os.environ.get("IMAGE_WORKER_URL", "").rstrip("/")
IMAGE_WORKER_TOKEN = os.environ.get("IMAGE_WORKER_TOKEN", "").strip()
-IMAGE_MODEL_NAME = os.environ.get("IMAGE_MODEL_NAME", "FLUX.2-klein-4B")
+IMAGE_MODEL_NAME = os.environ.get(
+ "IMAGE_MODEL_NAME", "FLUX.2-klein-9B-fp8-beta")
IMAGE_DIR = os.environ.get(
"IMAGE_DIR", "/opt/mike-ai/ai-profile-router/images")
IMAGE_WORKER_LOG = os.environ.get(
@@ -163,7 +165,7 @@ IMAGE_SIZES = {
"1920x1088": (1920, 1088),
"1088x1920": (1088, 1920),
}
-# Das destillierte FLUX.2-klein-4B ist auf vier Schritte ausgelegt.
+# Das destillierte FLUX.2 Klein 9B ist auf vier Schritte ausgelegt.
IMAGE_QUALITY = {"standard": 4, "high": 4}
IMAGE_DEFAULT_QUALITY = "standard"
IMAGE_MAX_N = 4
@@ -765,7 +767,7 @@ def switch_profile(profile: str, implicit: bool = False) -> None:
# ---------------------------------------------------------------------------
-# Bildgenerierung und Editing (FLUX.2-klein-4B)
+# Bildgenerierung und Editing (FLUX.2 Klein 9B FP8)
# ---------------------------------------------------------------------------
class _Worker:
@@ -1874,7 +1876,7 @@ class Handler(BaseHTTPRequestHandler):
return
steps = data.get("steps", IMAGE_QUALITY[quality])
if not isinstance(steps, int) or isinstance(steps, bool) or steps != 4:
- self._send_error(400, "FLUX.2-klein-4B erfordert 'steps'=4",
+ self._send_error(400, f"{IMAGE_MODEL_NAME} erfordert 'steps'=4",
"invalid_request_error", "invalid_steps")
return
guidance = data.get("guidance", 1.0)
@@ -1885,7 +1887,7 @@ class Handler(BaseHTTPRequestHandler):
"invalid_request_error", "invalid_guidance")
return
if guidance != 1.0:
- self._send_error(400, "FLUX.2-klein-4B erfordert 'guidance'=1.0",
+ self._send_error(400, f"{IMAGE_MODEL_NAME} erfordert 'guidance'=1.0",
"invalid_request_error", "invalid_guidance")
return
@@ -1986,7 +1988,7 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(data)
- # ---------- Sprachausgabe (XTTS-v2) ----------
+ # ---------- Sprachausgabe (Qwen3-TTS mit Piper-Fallback) ----------
def _speech(self) -> None:
try: