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: