Synchronize repository with Athena deployment
This commit is contained in:
+2
-9
@@ -3,10 +3,9 @@ AI_BIND_ADDRESS=10.77.0.2
|
|||||||
MODEL_DIR=/data/models
|
MODEL_DIR=/data/models
|
||||||
ROUTER_API_KEY=GENERATED_BY_INSTALLER
|
ROUTER_API_KEY=GENERATED_BY_INSTALLER
|
||||||
CONTROLLER_TOKEN=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
|
IMAGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
|
||||||
PIPER_TTS_VERSION=1.6.0
|
|
||||||
PIPER_VOICE=de_DE-thorsten-high
|
|
||||||
QWEN3_TTS_IMAGE=ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98
|
QWEN3_TTS_IMAGE=ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98
|
||||||
QWEN3_TTS_CACHE_DIR=/data/models/qwen3-tts-cache
|
QWEN3_TTS_CACHE_DIR=/data/models/qwen3-tts-cache
|
||||||
QWEN3_TTS_VOICES_DIR=/data/models/qwen3-tts-voices
|
QWEN3_TTS_VOICES_DIR=/data/models/qwen3-tts-voices
|
||||||
@@ -16,7 +15,6 @@ DEFAULT_REASONING_EFFORT=off
|
|||||||
|
|
||||||
FAST_MODEL_FILE=qwen-mix/Qwen3.8-27B-IQ4-MIX.gguf
|
FAST_MODEL_FILE=qwen-mix/Qwen3.8-27B-IQ4-MIX.gguf
|
||||||
MEDIUM_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
MEDIUM_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
BETA1_MODEL_FILE=qwen3.8-27b-gsq-rco-test/Qwen3.8-27B-GSQ-RCO-IQ3_XXS-mtp.gguf
|
|
||||||
LARGE_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
LARGE_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
ULTRA_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
ULTRA_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
UNCENSORED_MODEL_FILE=qwen3.8-27b-abliterated/Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf
|
UNCENSORED_MODEL_FILE=qwen3.8-27b-abliterated/Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf
|
||||||
@@ -29,9 +27,6 @@ FAST_UBATCH_SIZE=32
|
|||||||
MEDIUM_CONTEXT=160000
|
MEDIUM_CONTEXT=160000
|
||||||
MEDIUM_BATCH_SIZE=2048
|
MEDIUM_BATCH_SIZE=2048
|
||||||
MEDIUM_UBATCH_SIZE=128
|
MEDIUM_UBATCH_SIZE=128
|
||||||
BETA1_CONTEXT=192000
|
|
||||||
BETA1_BATCH_SIZE=2048
|
|
||||||
BETA1_UBATCH_SIZE=128
|
|
||||||
LARGE_CONTEXT=192000
|
LARGE_CONTEXT=192000
|
||||||
LARGE_BATCH_SIZE=2048
|
LARGE_BATCH_SIZE=2048
|
||||||
LARGE_UBATCH_SIZE=128
|
LARGE_UBATCH_SIZE=128
|
||||||
@@ -44,7 +39,6 @@ UNCENSORED_UBATCH_SIZE=128
|
|||||||
FAST_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
FAST_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
MEDIUM_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
MEDIUM_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
MEDIUM_TENSOR_SPLIT=85,15
|
MEDIUM_TENSOR_SPLIT=85,15
|
||||||
BETA1_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
|
||||||
LARGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
LARGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
LARGE_TENSOR_SPLIT=86,14
|
LARGE_TENSOR_SPLIT=86,14
|
||||||
ULTRA_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
ULTRA_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
@@ -57,7 +51,6 @@ LLAMA_THREADS_BATCH=6
|
|||||||
FAST_PARALLEL_SLOTS=1
|
FAST_PARALLEL_SLOTS=1
|
||||||
LLAMA_CACHE_RAM_MIB=32768
|
LLAMA_CACHE_RAM_MIB=32768
|
||||||
MEDIUM_PARALLEL_SLOTS=1
|
MEDIUM_PARALLEL_SLOTS=1
|
||||||
BETA1_PARALLEL_SLOTS=1
|
|
||||||
LARGE_PARALLEL_SLOTS=1
|
LARGE_PARALLEL_SLOTS=1
|
||||||
ULTRA_PARALLEL_SLOTS=1
|
ULTRA_PARALLEL_SLOTS=1
|
||||||
UNCENSORED_PARALLEL_SLOTS=1
|
UNCENSORED_PARALLEL_SLOTS=1
|
||||||
|
|||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
ATHENA – AUFBAU VON UNTEN NACH OBEN
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Stand: 10.09.2026 nach Entfernung von Beta 1 und Piper.
|
||||||
|
Athena besitzt derzeit 23 Container, fünf auswählbare LLM-Profile und vier
|
||||||
|
verwendete Docker-Volumes. Verwaiste Docker-Volumes gibt es nicht.
|
||||||
|
|
||||||
|
|
||||||
|
+--------------------------------------+
|
||||||
|
| PHYSISCHER RECHNER: ATHENA |
|
||||||
|
| |
|
||||||
|
| CPU, RAM, Systemplatte, Netzwerk |
|
||||||
|
| NVIDIA RTX 5080 + NVIDIA RTX 3060 |
|
||||||
|
+------------------+-------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+--------------------------------------+
|
||||||
|
| DEBIAN-HOSTSYSTEM |
|
||||||
|
| |
|
||||||
|
| - startet den Rechner |
|
||||||
|
| - verwaltet Netzwerk und Datenträger |
|
||||||
|
| - stellt NVIDIA-Treiber bereit |
|
||||||
|
| - führt Docker aus |
|
||||||
|
+------------------+-------------------+
|
||||||
|
|
|
||||||
|
+-------------------+-------------------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+----------------------------------+ +----------------------------------+
|
||||||
|
| DOCKER-STACK | | DAUERHAFTE DATEN AUF DEM HOST |
|
||||||
|
| | | |
|
||||||
|
| - Router und Profilsteuerung | | - Modelle und Modellgewichte |
|
||||||
|
| - llama.cpp-Modellserver | | - Trainingsdatensätze |
|
||||||
|
| - Athena-Dashboard | | - trainierte Stimmen |
|
||||||
|
| - Bild-, Musik- und Audiodienste | | - Checkpoints und Ergebnisse |
|
||||||
|
| - Applio und Mikes Applio UI | | - Konfigurationen und Logs |
|
||||||
|
| - Hilfs- und Netzwerkdienste | | |
|
||||||
|
+----------------+-----------------+ | Hauptpfade: |
|
||||||
|
| | /data |
|
||||||
|
| liest und schreibt | /etc/mike-ai |
|
||||||
|
+-------------------->| |
|
||||||
|
+----------------+-----------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------------------+
|
||||||
|
| BACKUP |
|
||||||
|
| |
|
||||||
|
| Sichert ausgewählte dauerhafte |
|
||||||
|
| Daten und Konfigurationen. |
|
||||||
|
+----------------------------------+
|
||||||
|
|
||||||
|
|
||||||
|
DOCKER-STACK: CONTAINER-INVENTAR
|
||||||
|
================================
|
||||||
|
|
||||||
|
Bestandsaufnahme vom 10.09.2026. "Gestoppt/bereit" bedeutet hier nicht
|
||||||
|
automatisch defekt: GPU-intensive Dienste werden absichtlich nur im passenden
|
||||||
|
Betriebsmodus gestartet. Zum Zeitpunkt der Aufnahme war Applio/RVC aktiv.
|
||||||
|
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| Container | Zustand | Aufgabe |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-router | läuft | Zentrale API; leitet Text-, Bild-, Audio- |
|
||||||
|
| | | und Profilanfragen an den passenden Dienst. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-profile-controller | läuft | Schaltet Profile und Betriebsmodi und sorgt |
|
||||||
|
| | | dafür, dass sich GPU-Dienste nicht stören. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-fast | gestoppt/bereit | llama.cpp-Textmodell mit kleinem Kontext und |
|
||||||
|
| | | hoher Geschwindigkeit. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-medium | gestoppt/bereit | llama.cpp-Textmodell mit mittlerem Kontext. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-large | gestoppt/bereit | llama.cpp-Textmodell mit großem Kontext. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-ultra | gestoppt/bereit | llama.cpp-Textmodell mit maximalem Kontext. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-uncensored | gestoppt/bereit | Separates ungefiltertes llama.cpp-Profil. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-image-worker | gestoppt/bereit | Lokale Bildgenerierung und Bildbearbeitung; |
|
||||||
|
| | | wird nur für Bildaufträge geladen. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-qwen3-tts | gestoppt/bereit | Hochwertige GPU-Sprachausgabe mit Qwen3-TTS. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-tts-gateway | läuft | Normalisiert Text, wandelt Audioformate und |
|
||||||
|
| | | streamt die Ausgabe von Qwen3-TTS. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-whisper | läuft | Lokale Spracherkennung: Sprache zu Text. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-music-acestep-test | gestoppt/bereit | ACE-Step 1.5: erzeugt und bearbeitet Musik. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-music-ui | läuft | Community-Weboberfläche für ACE-Step; das |
|
||||||
|
| | | eigentliche Musikmodell wird separat geladen.|
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-stem-separator | gestoppt/bereit | Trennt Gesang, Begleitung und Instrumente |
|
||||||
|
| | | mit BS-RoFormer und Demucs. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-voice-studio | gestoppt/bereit | Voice Studio für Text-zu-Stimme und |
|
||||||
|
| | | referenzbasierte Stimmerzeugung. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-xvc-studio | gestoppt/bereit | X-VC für direkte Stimme-zu-Stimme-Umwandlung |
|
||||||
|
| | | ohne vorheriges RVC-Training. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-applio-studio | läuft | Applio/RVC-Backend: Training, Modelle, |
|
||||||
|
| | | Sprachumwandlung und Original-Weboberfläche. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-mikes-applio-ui | läuft | Eigene geführte Oberfläche für das Applio- |
|
||||||
|
| | | Backend; enthält selbst kein RVC-Modell. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-dashboard | läuft | Athena-Dashboard: Zustand, Telemetrie und |
|
||||||
|
| | | Umschaltung der Betriebsmodi. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-portainer | läuft | Allgemeine Webverwaltung und Einsicht für |
|
||||||
|
| | | Docker-Container, Images, Netze und Volumes. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-wireguard-gateway | läuft | Stellt die Athena-Webdienste ausschließlich |
|
||||||
|
| | | über den privaten WireGuard-Zugang bereit. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-mcp-athena-operator | läuft | Kontrollierte Verwaltungswerkzeuge für |
|
||||||
|
| | | Athena, unter anderem für Hermes. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-backup | läuft | Sichert regelmäßig die dauerhaften Daten und |
|
||||||
|
| | | Konfigurationen von Athena. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
|
||||||
|
Die Container gehören technisch zu mehreren Compose-Projekten, werden hier
|
||||||
|
aber gemeinsam als Athena-Docker-Stack betrachtet:
|
||||||
|
|
||||||
|
- Kernsystem: /opt/mike-ai/stack
|
||||||
|
- Applio/RVC: /opt/mike-ai/stack/experiments/applio-rvc
|
||||||
|
- Mikes Applio UI: /opt/mike-ai/Mikes-Applio-UI
|
||||||
|
- ACE-Step-Musik: /opt/mike-ai/acestep-test
|
||||||
|
- Spurentrennung: /opt/mike-ai/stem-separator
|
||||||
|
- Voice Studio: /opt/mike-ai/omnivoice-studio
|
||||||
|
- X-VC: /opt/mike-ai/xvc-studio
|
||||||
|
|
||||||
|
|
||||||
|
LLM-PROFILE
|
||||||
|
===========
|
||||||
|
|
||||||
|
Es läuft immer höchstens eines dieser Profile. Fast, Medium, Large und
|
||||||
|
Uncensored können zusätzlich den Vision-Projektor verwenden. Ultra reserviert
|
||||||
|
den verfügbaren Speicher für den maximalen Textkontext und läuft ohne Vision.
|
||||||
|
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Profil | API-Modell | Kontext | Zweck |
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Fast | qwen-fast | 76.800 Token | Hohe Geschwindigkeit und |
|
||||||
|
| | | | kurze bis mittlere Aufgaben.|
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Medium | qwen-medium | 160.000 Token | Ausgewogenes Standardprofil.|
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Large | qwen-large | 192.000 Token | Umfangreiche Dokumente und |
|
||||||
|
| | | | lange technische Arbeiten. |
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Ultra | qwen-ultra | 262.144 Token | Maximaler Textkontext; ohne |
|
||||||
|
| | | | Vision-Projektor. |
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Uncensored | qwen-uncensored | 80.000 Token | Weniger restriktives |
|
||||||
|
| | | | Spezialprofil. |
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
|
||||||
|
Die produktiven Standardprofile verwenden Qwen3.8-27B in Q4-Quantisierung.
|
||||||
|
Das frühere Beta-1-Profil mit GSQ-RCO IQ3_S wurde entfernt: Es benötigte zwar
|
||||||
|
weniger Speicher, war im gemessenen Betrieb aber überwiegend langsamer und
|
||||||
|
brachte keinen belastbaren Qualitäts- oder Geschwindigkeitsvorteil.
|
||||||
|
|
||||||
|
|
||||||
|
BETRIEBSMODI UND GPU-UMSCHALTUNG
|
||||||
|
===============================
|
||||||
|
|
||||||
|
Die großen GPU-Dienste laufen gegenseitig exklusiv. Der Router speichert den
|
||||||
|
gewählten Zustand und die Profilsteuerung entlädt vor einem Wechsel die nicht
|
||||||
|
benötigten Modelle.
|
||||||
|
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Modus | Geladener Hauptdienst |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| LLM | Ein Qwen-LLM-Profil und Qwen3-TTS. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Musik | ACE-Step 1.5 für Musikgenerierung. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Audio trennen | BS-RoFormer, Demucs oder MossFormer2. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Voice Studio | OmniVoice für referenzbasierte Text-zu-Sprache-Ausgabe. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| X-VC | Direkte Stimme-zu-Stimme-Umwandlung. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Applio / RVC | RVC-Inferenz, Modellverwaltung und Stimmtraining. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
|
||||||
|
Qwen3-TTS läuft nur im LLM-Modus. In einem exklusiven Spezialmodus bleibt das
|
||||||
|
leichte TTS-Gateway als API-Dienst gesund, meldet aber "ready: false", weil das
|
||||||
|
eigentliche Qwen3-TTS-Modell absichtlich entladen ist.
|
||||||
|
|
||||||
|
|
||||||
|
TTS-AUFBAU
|
||||||
|
===========
|
||||||
|
|
||||||
|
+-----------------------------+
|
||||||
|
| Router / OpenAI-TTS-Endpunkt|
|
||||||
|
+--------------+--------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+-----------------------------+
|
||||||
|
| mike-ai-tts-gateway |
|
||||||
|
| - Text normalisieren |
|
||||||
|
| - Ausgabeformat umwandeln |
|
||||||
|
| - PCM-Streaming |
|
||||||
|
+--------------+--------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+-----------------------------+
|
||||||
|
| mike-ai-qwen3-tts |
|
||||||
|
| Qwen3-TTS 1.7B / Serena |
|
||||||
|
+-----------------------------+
|
||||||
|
|
||||||
|
Piper und sein CPU-Fallback wurden vollständig entfernt. Das TTS-Gateway
|
||||||
|
bleibt notwendig, weil es die stabile Schnittstelle und die Verarbeitung um
|
||||||
|
Qwen3-TTS herum bereitstellt. Wenn Qwen3-TTS nicht geladen ist, steht keine
|
||||||
|
Sprachausgabe zur Verfügung; es wird nicht mehr auf ein zweites Modell
|
||||||
|
zurückgegriffen.
|
||||||
|
|
||||||
|
|
||||||
|
DAUERHAFTE DOCKER-VOLUMES
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Bestandsprüfung vom 10.09.2026: Alle vier Volumes sind einem vorhandenen
|
||||||
|
Container zugeordnet. "docker volume ls -f dangling=true" liefert keine
|
||||||
|
Treffer.
|
||||||
|
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| Volume | Verwendung |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| mike-ai_router-state | Persistenter Routerzustand und Betriebsmodus. |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| mike-ai_router-images | Vom Router und Bilddienst erzeugte Bilder. |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| mike-ai_whisper-data | Lokales Whisper-Modell für Sprache-zu-Text. |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| portainer_data | Einstellungen und Daten von Portainer. |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
|
||||||
|
Das frühere Volume "mike-ai_piper-data" wurde zusammen mit Piper gelöscht.
|
||||||
|
Beta 1 besaß kein eigenes Docker-Volume; seine rund 12 GB Modellgewichte lagen
|
||||||
|
als Hostverzeichnis unter /data/models und wurden ebenfalls gelöscht.
|
||||||
|
|
||||||
|
Viele Fachdienste verwenden statt Docker-Volumes direkte Hostverzeichnisse.
|
||||||
|
Die wichtigsten davon sind:
|
||||||
|
|
||||||
|
- /data/models Modellgewichte und Modell-Caches
|
||||||
|
- /data/voice/applio Applio-Datensätze, Logs und Stimmenmodelle
|
||||||
|
- /data/music Musikprojekte und generierte Titel
|
||||||
|
- /data/audio/separation Ergebnisse der Audio- und Spurentrennung
|
||||||
|
- /data/llama-dashboard Verlauf und Zustandsdaten des Dashboards
|
||||||
|
- /etc/mike-ai betriebliche Konfiguration und Geheimnisse
|
||||||
|
- /data/docker-backups erzeugte Sicherungsarchive
|
||||||
|
|
||||||
|
Diese Verzeichnisse sind keine Docker-Volumes. Ein leerer Docker-Volume-Check
|
||||||
|
beweist deshalb nicht automatisch, dass unter /data keine alten Experiment-
|
||||||
|
oder Modelldateien mehr liegen.
|
||||||
|
|
||||||
|
|
||||||
|
BACKUP UND DISASTER RECOVERY
|
||||||
|
============================
|
||||||
|
|
||||||
|
Athena verwendet zwei Sicherungsebenen:
|
||||||
|
|
||||||
|
1. mike-ai-backup schreibt alle fünf Stunden ein lokales Schnellbackup nach
|
||||||
|
/data/docker-backups. Darin liegen /etc/mike-ai, ganz /opt/mike-ai sowie
|
||||||
|
Router- und Portainer-Zustand. Dieses Backup deckt den Ausfall der
|
||||||
|
Systemplatte ab, solange /data erhalten bleibt.
|
||||||
|
|
||||||
|
2. athena-disaster-backup schreibt nachts ein verschlüsseltes und
|
||||||
|
dedupliziertes Restic-Backup auf einen physisch anderen Speicher. Es enthält
|
||||||
|
zusätzlich eigene Stimmen, Trainingsdatensätze, Musik, Audioergebnisse,
|
||||||
|
Dashboard- und Projektdaten. Dieses Backup deckt den Ausfall der Datenplatte
|
||||||
|
und den gleichzeitigen Ausfall beider Platten ab.
|
||||||
|
|
||||||
|
Die reproduzierbaren Modellgewichte unter /data/models werden nicht extern
|
||||||
|
doppelt gespeichert. Bei Verlust der Datenplatte werden sie aus den
|
||||||
|
versionierten Quellen neu geladen. Das Whisper-Volume wird ebenfalls neu
|
||||||
|
erzeugt.
|
||||||
|
|
||||||
|
Nach Debian-Installation und dem Einhängen einer eigenen /data-Partition führt
|
||||||
|
disaster-recovery.sh den passenden Wiederaufbau aus:
|
||||||
|
|
||||||
|
- --scenario system: Systemplatte neu, alte Datenplatte vorhanden
|
||||||
|
- --scenario data: Datenplatte neu, Systemplatte vorhanden
|
||||||
|
- --scenario all: beide Platten neu
|
||||||
|
|
||||||
|
Das Skript formatiert keine Platten, führt keinen Neustart aus und beendet den
|
||||||
|
Wiederaufbau im sicheren LLM-Standardmodus. Details stehen in docs/RECOVERY.md.
|
||||||
|
|
||||||
|
Zusätzlich entstehen alle fünf Stunden unter /data/emergency-backups bis zu
|
||||||
|
fünf verschlüsselte Notfallpakete. Sie können mit Prüfsumme direkt aus dem
|
||||||
|
Athena-Dashboard heruntergeladen werden. Ein auf einen anderen Rechner
|
||||||
|
heruntergeladenes Paket kann statt des externen Restic-Speichers als Quelle
|
||||||
|
für den Daten- oder Totalausfall dienen. Auf /data verbliebene Pakete schützen
|
||||||
|
nicht gegen den Ausfall genau dieser Datenplatte.
|
||||||
|
|
||||||
|
|
||||||
|
ENTFERNTE KOMPONENTEN
|
||||||
|
=====================
|
||||||
|
|
||||||
|
- Beta 1 / qwen-beta-1: Profil, Containerdefinition, Container und
|
||||||
|
GSQ-RCO-IQ3_S-Modellgewichte entfernt. Der historische Testbericht bleibt
|
||||||
|
erhalten, damit das Modell nicht versehentlich erneut getestet wird.
|
||||||
|
- Piper: Containerdefinition, Container, Image, Datenvolume, Konfiguration und
|
||||||
|
WireGuard-Port 8091 entfernt.
|
||||||
|
|
||||||
|
|
||||||
|
WICHTIGES GRUNDPRINZIP
|
||||||
|
======================
|
||||||
|
|
||||||
|
Die Anwendungen laufen überwiegend in Docker-Containern. Container selbst
|
||||||
|
sind austauschbar und können aus den versionierten Stack-Dateien neu gebaut
|
||||||
|
werden. Modelle, Trainingsmaterial, Ergebnisse und betriebliche Einstellungen
|
||||||
|
liegen dagegen dauerhaft auf dem Debian-Host und werden in die Container
|
||||||
|
eingebunden.
|
||||||
|
|
||||||
|
Ein neu gebauter Container darf deshalb keine Nutzdaten vernichten. Für eine
|
||||||
|
vollständige Wiederherstellung werden jedoch sowohl das Git-Repository mit dem
|
||||||
|
Stack als auch eine Sicherung der dauerhaften Hostdaten benötigt.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Root-only configuration for the encrypted off-host Restic repository.
|
||||||
|
# Copy to /etc/mike-ai/disaster-backup.env and chmod 600.
|
||||||
|
#
|
||||||
|
# Recommended: mount an Unraid backup share at /mnt/athena-offsite and use:
|
||||||
|
RESTIC_REPOSITORY=/mnt/athena-offsite/restic
|
||||||
|
RESTIC_REQUIRE_MOUNT=/mnt/athena-offsite
|
||||||
|
|
||||||
|
# The password file must ALSO exist outside Athena (password manager/offline
|
||||||
|
# recovery USB). Without it a total-loss backup cannot be decrypted.
|
||||||
|
RESTIC_PASSWORD_FILE=/root/athena-restic-password
|
||||||
|
|
||||||
|
RESTIC_TAG=athena-disaster
|
||||||
|
RESTIC_KEEP_DAILY=14
|
||||||
|
RESTIC_KEEP_WEEKLY=8
|
||||||
|
RESTIC_KEEP_MONTHLY=12
|
||||||
|
|
||||||
|
# Set true only after the repository and credentials have been tested.
|
||||||
|
DISASTER_BACKUP_ENABLED=false
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
## Response Language Policy
|
||||||
|
|
||||||
|
Always answer in the language used in the user's latest message. If the user writes in German, answer entirely in German. If the user changes languages, follow the language of that latest message. Do not change the response language because system instructions, conversation history, tool descriptions, tool results, sources, quotations, or technical material use another language. Preserve names, commands, code, and established technical terms when translating them would reduce accuracy.
|
||||||
|
|
||||||
## Mandatory Research and Verification Policy
|
## Mandatory Research and Verification Policy
|
||||||
|
|
||||||
When an answer, decision, or planned action depends on external facts and uncertainty could materially affect the result, verify the relevant information before proceeding.
|
When an answer, decision, or planned action depends on external facts and uncertainty could materially affect the result, verify the relevant information before proceeding.
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ NVIDIA_MIN_DRIVER_MAJOR=570
|
|||||||
TEXT_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
|
TEXT_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
|
||||||
SECONDARY_GPU_DEVICES=GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
SECONDARY_GPU_DEVICES=GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
IMAGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
|
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
|
# Headless remote reachability. Firmware power-loss recovery is configured
|
||||||
# separately once at the physical machine.
|
# separately once at the physical machine.
|
||||||
@@ -56,9 +60,6 @@ FAST_MODEL_SHA256=54879ae8738d5938f46cb3b8cbf16bf42b8c85b7d68d7c73f062b612ec183e
|
|||||||
MEDIUM_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
MEDIUM_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
MEDIUM_MODEL_URL=https://huggingface.co/jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF/resolve/main/qwen3.8-27b-IQ4_XS-pure.gguf
|
MEDIUM_MODEL_URL=https://huggingface.co/jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF/resolve/main/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
MEDIUM_MODEL_SHA256=ea5a3c45d407f9b9e5d2c0d647f0ea600f486f6b86b92b56d0823ba073dae675
|
MEDIUM_MODEL_SHA256=ea5a3c45d407f9b9e5d2c0d647f0ea600f486f6b86b92b56d0823ba073dae675
|
||||||
BETA1_MODEL_FILE=qwen3.8-27b-gsq-rco-test/Qwen3.8-27B-GSQ-RCO-IQ3_XXS-mtp.gguf
|
|
||||||
BETA1_MODEL_URL=https://huggingface.co/ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF/resolve/main/Qwen3.8-27B-GSQ-RCO-IQ3_XXS-mtp.gguf
|
|
||||||
BETA1_MODEL_SHA256=63f29a2189a6b4cc31f81e093d3856ad293a5583114439f41ae1ed7af4093262
|
|
||||||
LARGE_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
LARGE_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
LARGE_MODEL_URL=https://huggingface.co/jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF/resolve/main/qwen3.8-27b-IQ4_XS-pure.gguf
|
LARGE_MODEL_URL=https://huggingface.co/jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF/resolve/main/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
LARGE_MODEL_SHA256=ea5a3c45d407f9b9e5d2c0d647f0ea600f486f6b86b92b56d0823ba073dae675
|
LARGE_MODEL_SHA256=ea5a3c45d407f9b9e5d2c0d647f0ea600f486f6b86b92b56d0823ba073dae675
|
||||||
@@ -85,11 +86,6 @@ MEDIUM_CONTEXT=160000
|
|||||||
MEDIUM_BATCH_SIZE=2048
|
MEDIUM_BATCH_SIZE=2048
|
||||||
MEDIUM_UBATCH_SIZE=128
|
MEDIUM_UBATCH_SIZE=128
|
||||||
MEDIUM_TENSOR_SPLIT=85,15
|
MEDIUM_TENSOR_SPLIT=85,15
|
||||||
BETA1_CONTEXT=192000
|
|
||||||
BETA1_BATCH_SIZE=2048
|
|
||||||
BETA1_UBATCH_SIZE=128
|
|
||||||
BETA1_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
|
||||||
BETA1_PARALLEL_SLOTS=1
|
|
||||||
LARGE_CONTEXT=192000
|
LARGE_CONTEXT=192000
|
||||||
LARGE_BATCH_SIZE=2048
|
LARGE_BATCH_SIZE=2048
|
||||||
LARGE_UBATCH_SIZE=128
|
LARGE_UBATCH_SIZE=128
|
||||||
@@ -111,8 +107,6 @@ MEDIUM_PARALLEL_SLOTS=1
|
|||||||
LARGE_PARALLEL_SLOTS=1
|
LARGE_PARALLEL_SLOTS=1
|
||||||
ULTRA_PARALLEL_SLOTS=1
|
ULTRA_PARALLEL_SLOTS=1
|
||||||
UNCENSORED_PARALLEL_SLOTS=1
|
UNCENSORED_PARALLEL_SLOTS=1
|
||||||
PIPER_TTS_VERSION=1.6.0
|
|
||||||
PIPER_VOICE=de_DE-thorsten-high
|
|
||||||
QWEN3_TTS_IMAGE=ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98
|
QWEN3_TTS_IMAGE=ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98
|
||||||
QWEN3_TTS_CACHE_DIR=/data/models/qwen3-tts-cache
|
QWEN3_TTS_CACHE_DIR=/data/models/qwen3-tts-cache
|
||||||
QWEN3_TTS_VOICES_DIR=/data/models/qwen3-tts-voices
|
QWEN3_TTS_VOICES_DIR=/data/models/qwen3-tts-voices
|
||||||
|
|||||||
@@ -27,18 +27,6 @@
|
|||||||
"mtp": 3,
|
"mtp": 3,
|
||||||
"description": "Ausgewogenes Standardprofil für Alltag und lange agentische Aufgaben."
|
"description": "Ausgewogenes Standardprofil für Alltag und lange agentische Aufgaben."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "beta1",
|
|
||||||
"alias": "qwen-beta-1",
|
|
||||||
"context": 192000,
|
|
||||||
"parallel_slots": 1,
|
|
||||||
"model_env": "BETA1_MODEL_FILE",
|
|
||||||
"model_family": "Qwen3.8-27B GSQ-RCO IQ3_XXS MTP",
|
|
||||||
"gpu_split": "5080 model / 3060 vision",
|
|
||||||
"vision": true,
|
|
||||||
"mtp": 3,
|
|
||||||
"description": "Beta 1: schnelles GSQ-RCO-Testprofil mit 192K Kontext und Vision-Projektor auf der RTX 3060."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "large",
|
"id": "large",
|
||||||
"alias": "qwen-large",
|
"alias": "qwen-large",
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(200, {
|
self._send_json(200, {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"ready": True,
|
"ready": True,
|
||||||
"voices": ["claribel"],
|
"voices": ["alloy"],
|
||||||
"default_voice": "claribel",
|
"default_voice": "alloy",
|
||||||
"load_errors": [],
|
"load_errors": [],
|
||||||
"sample_rate": SAMPLE_RATE,
|
"sample_rate": SAMPLE_RATE,
|
||||||
"uptime_seconds": 1.0,
|
"uptime_seconds": 1.0,
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MODULE_PATH = ROOT / "platform/llama-dashboard/app.py"
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status = 202
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return b'{"status":"accepted"}'
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardModeTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
"DASHBOARD_HISTORY_DB": str(Path(cls.tempdir.name) / "history.sqlite3"),
|
||||||
|
"DASHBOARD_BACKUP_DIR": str(Path(cls.tempdir.name) / "backups"),
|
||||||
|
"ROUTER_URL": "http://router.test:8081",
|
||||||
|
"ROUTER_API_KEY": "test-key",
|
||||||
|
}):
|
||||||
|
spec = importlib.util.spec_from_file_location("dashboard_app_test", MODULE_PATH)
|
||||||
|
cls.dashboard = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec.loader is not None
|
||||||
|
spec.loader.exec_module(cls.dashboard)
|
||||||
|
cls.backup_dir = Path(cls.tempdir.name) / "backups"
|
||||||
|
cls.backup_dir.mkdir()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.tempdir.cleanup()
|
||||||
|
|
||||||
|
def test_separation_mode_is_forwarded_to_router(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("separation")
|
||||||
|
|
||||||
|
self.assertEqual(status, 202)
|
||||||
|
self.assertEqual(body, {"status": "accepted"})
|
||||||
|
request = urlopen.call_args.args[0]
|
||||||
|
self.assertEqual(json.loads(request.data), {"mode": "separation"})
|
||||||
|
self.assertEqual(request.get_header("Authorization"), "Bearer test-key")
|
||||||
|
|
||||||
|
def test_voice_mode_is_forwarded_to_router(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("voice")
|
||||||
|
|
||||||
|
self.assertEqual(status, 202)
|
||||||
|
self.assertEqual(body, {"status": "accepted"})
|
||||||
|
request = urlopen.call_args.args[0]
|
||||||
|
self.assertEqual(json.loads(request.data), {"mode": "voice"})
|
||||||
|
|
||||||
|
def test_voice_change_mode_is_forwarded_to_router(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("voicechange")
|
||||||
|
|
||||||
|
self.assertEqual(status, 202)
|
||||||
|
self.assertEqual(body, {"status": "accepted"})
|
||||||
|
request = urlopen.call_args.args[0]
|
||||||
|
self.assertEqual(json.loads(request.data), {"mode": "voicechange"})
|
||||||
|
|
||||||
|
def test_applio_mode_is_forwarded_to_router(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("applio")
|
||||||
|
self.assertEqual(status, 202)
|
||||||
|
self.assertEqual(body, {"status": "accepted"})
|
||||||
|
self.assertEqual(json.loads(urlopen.call_args.args[0].data), {"mode": "applio"})
|
||||||
|
|
||||||
|
def test_unknown_mode_is_rejected_without_router_request(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen") as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("unknown")
|
||||||
|
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertEqual(body, {"error": "invalid mode"})
|
||||||
|
urlopen.assert_not_called()
|
||||||
|
|
||||||
|
def test_dashboard_uses_one_status_poll_for_all_mode_labels(self):
|
||||||
|
html = self.dashboard.HTML
|
||||||
|
|
||||||
|
self.assertEqual(html.count("fetch('/api/status'"), 1)
|
||||||
|
self.assertNotIn("refreshVoiceChange", html)
|
||||||
|
self.assertIn("voicechange:'X-VC Voice Changer'", html)
|
||||||
|
self.assertIn("applio:'Applio / RVC'", html)
|
||||||
|
|
||||||
|
def test_dashboard_offers_both_applio_frontends(self):
|
||||||
|
html = self.dashboard.HTML
|
||||||
|
|
||||||
|
self.assertIn("Original Applio UI", html)
|
||||||
|
self.assertIn("Mikes Applio UI", html)
|
||||||
|
self.assertIn("http://192.168.1.212:8011/", html)
|
||||||
|
self.assertIn("http://192.168.1.212:8012/", html)
|
||||||
|
|
||||||
|
def test_dashboard_lists_only_portable_encrypted_backups(self):
|
||||||
|
valid = self.backup_dir / "athena-portable-2026-09-10T10-00-00Z.tar.zst.age"
|
||||||
|
valid.write_bytes(b"encrypted")
|
||||||
|
valid.with_name(valid.name + ".sha256").write_text(
|
||||||
|
"a" * 64 + " " + valid.name + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(self.backup_dir / "unrelated.txt").write_text("ignore", encoding="utf-8")
|
||||||
|
|
||||||
|
backups = self.dashboard.backup_inventory()
|
||||||
|
|
||||||
|
self.assertEqual([item["name"] for item in backups], [valid.name])
|
||||||
|
self.assertEqual(backups[0]["sha256"], "a" * 64)
|
||||||
|
self.assertTrue(backups[0]["download_url"].startswith("/api/backups/download/"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+13
-13
@@ -561,14 +561,14 @@ d=json.load(sys.stdin)
|
|||||||
tts=d["tts"]
|
tts=d["tts"]
|
||||||
assert tts["reachable"] is True, tts
|
assert tts["reachable"] is True, tts
|
||||||
assert tts["ready"] is True, tts
|
assert tts["ready"] is True, tts
|
||||||
assert set(tts["voices"])=={"claribel"}, tts
|
assert set(tts["voices"])=={"alloy"}, tts
|
||||||
' && ok "Status: TTS erreichbar, bereit, 2 Stimmen" || bad "Status tts-Section"
|
' && ok "Status: TTS erreichbar und bereit" || bad "Status tts-Section"
|
||||||
|
|
||||||
# --- 28. TTS: POST /v1/audio/speech (wav) ---------------------------------------------------------------
|
# --- 28. TTS: POST /v1/audio/speech (wav) ---------------------------------------------------------------
|
||||||
echo "== Test 28: POST /v1/audio/speech (wav)"
|
echo "== Test 28: POST /v1/audio/speech (wav)"
|
||||||
CODE=$(curl -s -o /tmp/tts28.wav -w "%{http_code}" -D /tmp/hdr28.txt \
|
CODE=$(curl -s -o /tmp/tts28.wav -w "%{http_code}" -D /tmp/hdr28.txt \
|
||||||
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
||||||
-d '{"model":"xtts-v2","input":"Hallo Welt","voice":"claribel","response_format":"wav"}')
|
-d '{"model":"qwen3-tts","input":"Hallo Welt","voice":"alloy","response_format":"wav"}')
|
||||||
CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r")
|
CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r")
|
||||||
[ "$CODE" = "200" ] && [ -s /tmp/tts28.wav ] && echo "$CTYPE" | grep -qi "audio/wav" \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts28.wav ] && echo "$CTYPE" | grep -qi "audio/wav" \
|
||||||
&& ok "TTS wav (200, $CTYPE, $(stat -f%z /tmp/tts28.wav 2>/dev/null || stat -c%s /tmp/tts28.wav) Bytes)" \
|
&& ok "TTS wav (200, $CTYPE, $(stat -f%z /tmp/tts28.wav 2>/dev/null || stat -c%s /tmp/tts28.wav) Bytes)" \
|
||||||
@@ -578,7 +578,7 @@ CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r")
|
|||||||
echo "== Test 29: POST /v1/audio/speech (mp3, Default)"
|
echo "== Test 29: POST /v1/audio/speech (mp3, Default)"
|
||||||
CODE=$(curl -s -o /tmp/tts29.mp3 -w "%{http_code}" -D /tmp/hdr29.txt \
|
CODE=$(curl -s -o /tmp/tts29.mp3 -w "%{http_code}" -D /tmp/hdr29.txt \
|
||||||
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
||||||
-d '{"input":"Guten Tag","voice":"claribel"}')
|
-d '{"input":"Guten Tag","voice":"alloy"}')
|
||||||
CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r")
|
CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r")
|
||||||
[ "$CODE" = "200" ] && [ -s /tmp/tts29.mp3 ] && echo "$CTYPE" | grep -qi "audio/mpeg" \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts29.mp3 ] && echo "$CTYPE" | grep -qi "audio/mpeg" \
|
||||||
&& ok "TTS mp3 (200, $CTYPE)" || bad "TTS mp3 (Code $CODE, $CTYPE)"
|
&& ok "TTS mp3 (200, $CTYPE)" || bad "TTS mp3 (Code $CODE, $CTYPE)"
|
||||||
@@ -586,7 +586,7 @@ CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r")
|
|||||||
# --- 30. TTS: Validierung --------------------------------------------------------------------------------
|
# --- 30. TTS: Validierung --------------------------------------------------------------------------------
|
||||||
echo "== Test 30: TTS-Validierung"
|
echo "== Test 30: TTS-Validierung"
|
||||||
CODE=$(curl -s -o /tmp/err30a.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
CODE=$(curl -s -o /tmp/err30a.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
||||||
-H "Content-Type: application/json" -d '{"voice":"claribel"}')
|
-H "Content-Type: application/json" -d '{"voice":"alloy"}')
|
||||||
cat /tmp/err30a.json; echo
|
cat /tmp/err30a.json; echo
|
||||||
[ "$CODE" = "400" ] && ok "400 bei fehlendem input" || bad "erwartet 400, bekam $CODE"
|
[ "$CODE" = "400" ] && ok "400 bei fehlendem input" || bad "erwartet 400, bekam $CODE"
|
||||||
|
|
||||||
@@ -608,7 +608,7 @@ cat /tmp/err30d.json; echo
|
|||||||
# --- 31. TTS: Worker-Fehler → 503 ------------------------------------------------------------------------
|
# --- 31. TTS: Worker-Fehler → 503 ------------------------------------------------------------------------
|
||||||
echo "== Test 31: TTS-Worker-Fehler → 503"
|
echo "== Test 31: TTS-Worker-Fehler → 503"
|
||||||
CODE=$(curl -s -o /tmp/err31.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
CODE=$(curl -s -o /tmp/err31.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
||||||
-H "Content-Type: application/json" -d '{"input":"FAIL","voice":"claribel"}')
|
-H "Content-Type: application/json" -d '{"input":"FAIL","voice":"alloy"}')
|
||||||
cat /tmp/err31.json; echo
|
cat /tmp/err31.json; echo
|
||||||
[ "$CODE" = "503" ] && ok "503 bei TTS-Worker-Fehler" || bad "erwartet 503, bekam $CODE"
|
[ "$CODE" = "503" ] && ok "503 bei TTS-Worker-Fehler" || bad "erwartet 503, bekam $CODE"
|
||||||
|
|
||||||
@@ -617,7 +617,7 @@ echo "== Test 32: TTS-Worker down → 503"
|
|||||||
kill "$TTS_PID" 2>/dev/null || true
|
kill "$TTS_PID" 2>/dev/null || true
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
CODE=$(curl -s -o /tmp/err32.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
CODE=$(curl -s -o /tmp/err32.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
||||||
-H "Content-Type: application/json" -d '{"input":"Hallo","voice":"claribel"}')
|
-H "Content-Type: application/json" -d '{"input":"Hallo","voice":"alloy"}')
|
||||||
cat /tmp/err32.json; echo
|
cat /tmp/err32.json; echo
|
||||||
[ "$CODE" = "503" ] && ok "503 bei downem TTS-Worker" || bad "erwartet 503, bekam $CODE"
|
[ "$CODE" = "503" ] && ok "503 bei downem TTS-Worker" || bad "erwartet 503, bekam $CODE"
|
||||||
RESP=$(curl -sf "$BASE/status")
|
RESP=$(curl -sf "$BASE/status")
|
||||||
@@ -634,7 +634,7 @@ MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \
|
|||||||
TTS_PID=$!
|
TTS_PID=$!
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
CODE=$(curl -s -o /tmp/tts33.wav -w "%{http_code}" "$BASE/v1/audio/speech" \
|
CODE=$(curl -s -o /tmp/tts33.wav -w "%{http_code}" "$BASE/v1/audio/speech" \
|
||||||
-H "Content-Type: application/json" -d '{"input":"Wieder da","voice":"claribel","response_format":"wav"}')
|
-H "Content-Type: application/json" -d '{"input":"Wieder da","voice":"alloy","response_format":"wav"}')
|
||||||
[ "$CODE" = "200" ] && [ -s /tmp/tts33.wav ] \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts33.wav ] \
|
||||||
&& ok "TTS nach Neustart wieder verfügbar" || bad "TTS-Recovery (Code $CODE)"
|
&& ok "TTS nach Neustart wieder verfügbar" || bad "TTS-Recovery (Code $CODE)"
|
||||||
|
|
||||||
@@ -727,8 +727,8 @@ import json,sys
|
|||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
ids={m["id"] for m in d["data"]}
|
ids={m["id"] for m in d["data"]}
|
||||||
assert "whisper-1" in ids, ids
|
assert "whisper-1" in ids, ids
|
||||||
assert "xtts-v2" in ids, ids
|
assert "qwen3-tts" in ids, ids
|
||||||
' && ok "Audio-Modelle: whisper-1 + xtts-v2" || bad "Audio-Modelle"
|
' && ok "Audio-Modelle: whisper-1 + qwen3-tts" || bad "Audio-Modelle"
|
||||||
|
|
||||||
# --- 41. /v1/audio/voices ------------------------------------------------------------------------------------------
|
# --- 41. /v1/audio/voices ------------------------------------------------------------------------------------------
|
||||||
echo "== Test 41: GET /v1/audio/voices"
|
echo "== Test 41: GET /v1/audio/voices"
|
||||||
@@ -738,8 +738,8 @@ echo "$RESP" | python3 -c '
|
|||||||
import json,sys
|
import json,sys
|
||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
ids={v["id"] for v in d["data"]}
|
ids={v["id"] for v in d["data"]}
|
||||||
assert "claribel" in ids, ids
|
assert "alloy" in ids, ids
|
||||||
' && ok "Audio-Voices: claribel" || bad "Audio-Voices"
|
' && ok "Audio-Voices: alloy" || bad "Audio-Voices"
|
||||||
|
|
||||||
# --- 42. STT + Qwen parallel ----------------------------------------------------------------------------------------
|
# --- 42. STT + Qwen parallel ----------------------------------------------------------------------------------------
|
||||||
echo "== Test 42: STT + Qwen parallel"
|
echo "== Test 42: STT + Qwen parallel"
|
||||||
@@ -770,7 +770,7 @@ sleep 0.2
|
|||||||
# TTS-Request
|
# TTS-Request
|
||||||
CODE=$(curl -s -o /tmp/tts43.mp3 -w "%{http_code}" \
|
CODE=$(curl -s -o /tmp/tts43.mp3 -w "%{http_code}" \
|
||||||
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
||||||
-d '{"input":"Hallo","voice":"claribel"}')
|
-d '{"input":"Hallo","voice":"alloy"}')
|
||||||
wait $STT_PID43
|
wait $STT_PID43
|
||||||
[ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \
|
||||||
&& ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)"
|
&& ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)"
|
||||||
|
|||||||
@@ -26,7 +26,48 @@ def image_item(state="exited"):
|
|||||||
"Labels": {controller.IMAGE_LABEL_KEY: controller.IMAGE_WORKER}}
|
"Labels": {controller.IMAGE_LABEL_KEY: controller.IMAGE_WORKER}}
|
||||||
|
|
||||||
|
|
||||||
|
def restore_item(state="exited"):
|
||||||
|
return {"Id": "id-restore", "State": state,
|
||||||
|
"Labels": {controller.IMAGE_LABEL_KEY: controller.RESTORE_WORKER}}
|
||||||
|
|
||||||
|
|
||||||
|
def tts_item(state="running"):
|
||||||
|
return {"Id": "id-tts", "State": state,
|
||||||
|
"Labels": {controller.TTS_LABEL_KEY: controller.TTS_WORKER}}
|
||||||
|
|
||||||
|
|
||||||
|
def music_item(state="exited"):
|
||||||
|
return {"Id": "id-music", "State": state,
|
||||||
|
"Labels": {controller.MUSIC_LABEL_KEY: "acestep"}}
|
||||||
|
|
||||||
|
|
||||||
class ProfileControllerTests(unittest.TestCase):
|
class ProfileControllerTests(unittest.TestCase):
|
||||||
|
def test_music_start_exclusively_stops_gpu_workers(self):
|
||||||
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
|
profiles["ultra"] = item("ultra", "running")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request(method, path):
|
||||||
|
calls.append((method, path))
|
||||||
|
return 204, b""
|
||||||
|
|
||||||
|
with patch.object(controller, "MUSIC_WORKER", "acestep"), \
|
||||||
|
patch.object(controller, "containers", return_value=profiles), \
|
||||||
|
patch.object(controller, "music_container", return_value=music_item()), \
|
||||||
|
patch.object(controller, "image_containers",
|
||||||
|
return_value=[image_item("running")]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
|
result = controller.set_music_worker(True)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"music_worker": "acestep", "state": "running"})
|
||||||
|
self.assertEqual(calls, [
|
||||||
|
("POST", "/containers/id-ultra/stop?t=120"),
|
||||||
|
("POST", "/containers/id-flux/stop?t=20"),
|
||||||
|
("POST", "/containers/id-tts/stop?t=30"),
|
||||||
|
("POST", "/containers/id-music/start"),
|
||||||
|
])
|
||||||
|
|
||||||
def test_rejects_unknown_profile_before_docker_call(self):
|
def test_rejects_unknown_profile_before_docker_call(self):
|
||||||
with patch.object(controller, "docker_request") as request:
|
with patch.object(controller, "docker_request") as request:
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@@ -43,7 +84,8 @@ class ProfileControllerTests(unittest.TestCase):
|
|||||||
return 204, b""
|
return 204, b""
|
||||||
|
|
||||||
with patch.object(controller, "containers", return_value=profiles), \
|
with patch.object(controller, "containers", return_value=profiles), \
|
||||||
patch.object(controller, "image_container", return_value=image_item()), \
|
patch.object(controller, "image_containers", return_value=[image_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
patch.object(controller, "docker_request", side_effect=request):
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
result = controller.activate("medium")
|
result = controller.activate("medium")
|
||||||
|
|
||||||
@@ -56,7 +98,8 @@ class ProfileControllerTests(unittest.TestCase):
|
|||||||
def test_fails_if_profile_container_is_missing(self):
|
def test_fails_if_profile_container_is_missing(self):
|
||||||
profiles = {name: item(name) for name in controller.ALLOWED[:-1]}
|
profiles = {name: item(name) for name in controller.ALLOWED[:-1]}
|
||||||
with patch.object(controller, "containers", return_value=profiles), \
|
with patch.object(controller, "containers", return_value=profiles), \
|
||||||
patch.object(controller, "image_container", return_value=image_item()):
|
patch.object(controller, "image_containers", return_value=[image_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()):
|
||||||
with self.assertRaisesRegex(RuntimeError, "missing"):
|
with self.assertRaisesRegex(RuntimeError, "missing"):
|
||||||
controller.activate("fast")
|
controller.activate("fast")
|
||||||
|
|
||||||
@@ -71,13 +114,38 @@ class ProfileControllerTests(unittest.TestCase):
|
|||||||
|
|
||||||
with patch.object(controller, "containers", return_value=profiles), \
|
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, "image_containers",
|
||||||
|
return_value=[image_item(), restore_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
patch.object(controller, "docker_request", side_effect=request):
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
controller.set_image_worker(True)
|
controller.set_image_worker(True)
|
||||||
self.assertEqual(calls, [
|
self.assertEqual(calls, [
|
||||||
("POST", "/containers/id-medium/stop?t=120"),
|
("POST", "/containers/id-medium/stop?t=120"),
|
||||||
|
("POST", "/containers/id-tts/stop?t=30"),
|
||||||
("POST", "/containers/id-flux/start"),
|
("POST", "/containers/id-flux/start"),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def test_restore_start_stops_flux_and_starts_restore(self):
|
||||||
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request(method, path):
|
||||||
|
calls.append((method, path))
|
||||||
|
return 204, b""
|
||||||
|
|
||||||
|
with patch.object(controller, "containers", return_value=profiles), \
|
||||||
|
patch.object(controller, "image_container", return_value=restore_item()), \
|
||||||
|
patch.object(controller, "image_containers",
|
||||||
|
return_value=[image_item("running"), restore_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
|
controller.set_image_worker(True, controller.RESTORE_WORKER)
|
||||||
|
self.assertEqual(calls, [
|
||||||
|
("POST", "/containers/id-tts/stop?t=30"),
|
||||||
|
("POST", "/containers/id-flux/stop?t=20"),
|
||||||
|
("POST", "/containers/id-restore/start"),
|
||||||
|
])
|
||||||
|
|
||||||
def test_profile_activation_stops_image_worker_first(self):
|
def test_profile_activation_stops_image_worker_first(self):
|
||||||
profiles = {name: item(name) for name in controller.ALLOWED}
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
calls = []
|
calls = []
|
||||||
@@ -87,8 +155,9 @@ class ProfileControllerTests(unittest.TestCase):
|
|||||||
return 204, b""
|
return 204, b""
|
||||||
|
|
||||||
with patch.object(controller, "containers", return_value=profiles), \
|
with patch.object(controller, "containers", return_value=profiles), \
|
||||||
patch.object(controller, "image_container",
|
patch.object(controller, "image_containers",
|
||||||
return_value=image_item("running")), \
|
return_value=[image_item("running"), restore_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
patch.object(controller, "docker_request", side_effect=request):
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
controller.activate("fast")
|
controller.activate("fast")
|
||||||
self.assertEqual(calls, [
|
self.assertEqual(calls, [
|
||||||
|
|||||||
+34
-8
@@ -82,19 +82,45 @@ class RecoveryScriptTests(unittest.TestCase):
|
|||||||
for profile in ("fast", "medium", "large", "ultra", "uncensored"):
|
for profile in ("fast", "medium", "large", "ultra", "uncensored"):
|
||||||
self.assertIn(f"llama-{profile}", installer)
|
self.assertIn(f"llama-{profile}", installer)
|
||||||
|
|
||||||
def test_gateway_consumers_are_stopped_before_gateway_recreation(self) -> None:
|
def test_gateway_consumers_do_not_require_rebinding(self) -> None:
|
||||||
manager = (ROOT / "manage.sh").read_text(encoding="utf-8")
|
manager = (ROOT / "manage.sh").read_text(encoding="utf-8")
|
||||||
stop = 'stop llama-dashboard portainer'
|
installer = (ROOT / "install.sh").read_text(encoding="utf-8")
|
||||||
deploy = 'up -d --build'
|
self.assertNotIn('stop llama-dashboard portainer', manager)
|
||||||
self.assertIn(stop, manager)
|
self.assertNotIn('stop llama-dashboard portainer', installer)
|
||||||
self.assertLess(manager.index(stop), manager.index(deploy))
|
self.assertNotIn('force-recreate llama-dashboard portainer', manager)
|
||||||
|
|
||||||
def test_gateway_owns_shared_ui_ports_and_portainer_backup(self) -> None:
|
def test_gateway_proxies_stable_ui_services_and_portainer_backup(self) -> None:
|
||||||
compose = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
compose = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
||||||
self.assertIn('"8099:8099"', compose)
|
gateway = (ROOT / "platform/docker/wireguard-gateway/entrypoint.sh").read_text(
|
||||||
self.assertIn('"9443:9443"', compose)
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertNotIn('network_mode: "service:wireguard-gateway"', compose)
|
||||||
|
self.assertNotIn('"8099:8099"', compose)
|
||||||
|
self.assertNotIn('"9443:9443"', compose)
|
||||||
|
self.assertIn('start_proxy 8099 llama-dashboard:8099', gateway)
|
||||||
|
self.assertIn('start_proxy 9443 portainer:9443', gateway)
|
||||||
self.assertIn('portainer-data:/backup/volumes/portainer-data:ro', compose)
|
self.assertIn('portainer-data:/backup/volumes/portainer-data:ro', compose)
|
||||||
|
|
||||||
|
def test_disaster_recovery_covers_all_three_scenarios_without_formatting(self) -> None:
|
||||||
|
script = (ROOT / "disaster-recovery.sh").read_text(encoding="utf-8")
|
||||||
|
for scenario in ("system", "data", "all"):
|
||||||
|
self.assertIn(scenario, script)
|
||||||
|
self.assertIn("mountpoint -q /data", script)
|
||||||
|
self.assertIn("--portable", script)
|
||||||
|
for destructive in ("mkfs", "fdisk", "parted", "reboot", "shutdown"):
|
||||||
|
self.assertNotIn(f"{destructive} ", script)
|
||||||
|
|
||||||
|
def test_backup_layers_include_code_and_irreplaceable_data(self) -> None:
|
||||||
|
compose = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
||||||
|
export = (ROOT / "platform/backup/athena-export-backup").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertIn("/opt/mike-ai:/backup/opt-mike-ai:ro", compose)
|
||||||
|
self.assertIn("/data/voice/applio/logs", export)
|
||||||
|
self.assertIn("/data/voice/applio/datasets", export)
|
||||||
|
self.assertIn("ATHENA_EXPORT_KEEP:-5", export)
|
||||||
|
self.assertNotIn("add_path /data/models", export)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Executable
+172
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-shot recovery orchestrator for system-disk, data-disk and total loss.
|
||||||
|
# It never partitions, formats, reboots or shuts down the host.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SCENARIO=""
|
||||||
|
ARCHIVE=/data/docker-backups/athena-latest.tar.gz
|
||||||
|
RECOVERY_CONFIG=""
|
||||||
|
INSTALL_CONFIG=""
|
||||||
|
SNAPSHOT=latest
|
||||||
|
PORTABLE=""
|
||||||
|
AGE_IDENTITY=""
|
||||||
|
WORK=""
|
||||||
|
|
||||||
|
log() { printf '\n==> %s\n' "$*"; }
|
||||||
|
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||||
|
cleanup() { [[ -z $WORK ]] || rm -rf "$WORK"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Systemplatte defekt, vorhandene /data-Platte:
|
||||||
|
sudo ./disaster-recovery.sh --scenario system \
|
||||||
|
--archive /data/docker-backups/athena-latest.tar.gz
|
||||||
|
|
||||||
|
Datenplatte defekt, Systemplatte vorhanden:
|
||||||
|
sudo ./disaster-recovery.sh --scenario data \
|
||||||
|
--config /root/athena-recovery.env
|
||||||
|
|
||||||
|
Beide Platten neu:
|
||||||
|
sudo ./disaster-recovery.sh --scenario all \
|
||||||
|
--config /root/athena-recovery.env
|
||||||
|
|
||||||
|
Alternativ bei Daten-/Totalausfall mit einem zuvor heruntergeladenen Paket:
|
||||||
|
sudo ./disaster-recovery.sh --scenario all \
|
||||||
|
--portable /pfad/athena-portable-....tar.zst.age \
|
||||||
|
--identity /root/athena-recovery-key.txt
|
||||||
|
|
||||||
|
Voraussetzung: Debian ist installiert und die richtige, bereits formatierte
|
||||||
|
Datenpartition ist separat unter /data eingehängt. Dieses Skript formatiert
|
||||||
|
keine Datenträger und führt niemals selbst einen Neustart aus.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--scenario) SCENARIO=${2:-}; shift 2 ;;
|
||||||
|
--archive) ARCHIVE=${2:-}; shift 2 ;;
|
||||||
|
--config) RECOVERY_CONFIG=${2:-}; shift 2 ;;
|
||||||
|
--install-config) INSTALL_CONFIG=${2:-}; shift 2 ;;
|
||||||
|
--snapshot) SNAPSHOT=${2:-}; shift 2 ;;
|
||||||
|
--portable) PORTABLE=${2:-}; shift 2 ;;
|
||||||
|
--identity) AGE_IDENTITY=${2:-}; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "Unbekanntes Argument: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||||
|
[[ $SCENARIO == system || $SCENARIO == data || $SCENARIO == all ]] || \
|
||||||
|
die "--scenario muss system, data oder all sein."
|
||||||
|
mountpoint -q /data || die "/data ist kein eigener Mountpoint. Abbruch zum Schutz der Systemplatte."
|
||||||
|
|
||||||
|
install_bootstrap_packages() {
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates gzip rsync tar restic
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_tree() {
|
||||||
|
local source=$1 target=$2
|
||||||
|
[[ -d $source ]] || return 0
|
||||||
|
install -d -m 0755 "$target"
|
||||||
|
rsync -a "$source/" "$target/"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_local_bootstrap() {
|
||||||
|
[[ -s $ARCHIVE ]] || die "Lokales Backup fehlt: $ARCHIVE"
|
||||||
|
gzip -t "$ARCHIVE" || die "Lokales Backup ist beschädigt."
|
||||||
|
WORK=$(mktemp -d /tmp/athena-system-recovery.XXXXXX)
|
||||||
|
tar -xzf "$ARCHIVE" -C "$WORK"
|
||||||
|
[[ -d $WORK/backup/etc-mike-ai ]] || die "Backup enthält /etc/mike-ai nicht."
|
||||||
|
copy_tree "$WORK/backup/etc-mike-ai" /etc/mike-ai
|
||||||
|
if [[ -d $WORK/backup/opt-mike-ai ]]; then
|
||||||
|
copy_tree "$WORK/backup/opt-mike-ai" /opt/mike-ai
|
||||||
|
elif [[ -d $WORK/backup/stack ]]; then
|
||||||
|
copy_tree "$WORK/backup/stack" /opt/mike-ai/stack
|
||||||
|
fi
|
||||||
|
install -d -m 0700 /var/lib/mike-ai-disaster-backup/latest
|
||||||
|
install -m 0600 "$ARCHIVE" \
|
||||||
|
/var/lib/mike-ai-disaster-backup/latest/docker-state.tar.gz
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_external_snapshot() {
|
||||||
|
[[ -r $RECOVERY_CONFIG ]] || die "Externe Recovery-Konfiguration fehlt: $RECOVERY_CONFIG"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$RECOVERY_CONFIG"
|
||||||
|
[[ -n ${RESTIC_REPOSITORY:-} ]] || die "RESTIC_REPOSITORY fehlt."
|
||||||
|
[[ -n ${RESTIC_PASSWORD_FILE:-} && -r $RESTIC_PASSWORD_FILE ]] || die \
|
||||||
|
"Der separat aufzubewahrende Restic-Schlüssel fehlt."
|
||||||
|
WORK=$(mktemp -d /tmp/athena-offsite-recovery.XXXXXX)
|
||||||
|
restic restore "$SNAPSHOT" --tag "${RESTIC_TAG:-athena-disaster}" --target "$WORK"
|
||||||
|
[[ -d $WORK/data ]] || die "Snapshot enthält keine Athena-Daten."
|
||||||
|
copy_tree "$WORK/data" /data
|
||||||
|
if [[ $SCENARIO == all ]]; then
|
||||||
|
copy_tree "$WORK/etc/mike-ai" /etc/mike-ai
|
||||||
|
copy_tree "$WORK/opt/mike-ai" /opt/mike-ai
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_portable_archive() {
|
||||||
|
[[ -s $PORTABLE ]] || die "Portables Backup fehlt: $PORTABLE"
|
||||||
|
[[ -r $AGE_IDENTITY ]] || die "Age-Identität fehlt: $AGE_IDENTITY"
|
||||||
|
WORK=$(mktemp -d /tmp/athena-portable-recovery.XXXXXX)
|
||||||
|
age --decrypt -i "$AGE_IDENTITY" "$PORTABLE" | zstd -d | tar -xf - -C "$WORK"
|
||||||
|
[[ -d $WORK/data ]] || die "Portables Backup enthält keine Athena-Daten."
|
||||||
|
copy_tree "$WORK/data" /data
|
||||||
|
if [[ $SCENARIO == all ]]; then
|
||||||
|
copy_tree "$WORK/etc/mike-ai" /etc/mike-ai
|
||||||
|
copy_tree "$WORK/opt/mike-ai" /opt/mike-ai
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_installer() {
|
||||||
|
local config=${INSTALL_CONFIG:-/etc/mike-ai/install.env}
|
||||||
|
[[ -r $config ]] || die \
|
||||||
|
"Installationskonfiguration fehlt: $config (alternativ --install-config angeben)."
|
||||||
|
chmod 0600 "$config"
|
||||||
|
[[ -x /opt/mike-ai/stack/install.sh ]] || die "Wiederhergestellter Stack fehlt."
|
||||||
|
set +e
|
||||||
|
/opt/mike-ai/stack/install.sh --config "$config"
|
||||||
|
local rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ $rc == 20 || $rc == 21 ]]; then
|
||||||
|
printf '\nEin kontrollierter Neustart ist für Treiber/Netzwerk nötig.\n'
|
||||||
|
printf 'Danach exakt denselben Disaster-Recovery-Befehl erneut ausführen.\n'
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
[[ $rc == 0 ]] || die "Installer fehlgeschlagen (Exit $rc)."
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_docker_state() {
|
||||||
|
local state=/var/lib/mike-ai-disaster-backup/latest/docker-state.tar.gz
|
||||||
|
if [[ ! -s $state && -n $WORK ]]; then
|
||||||
|
state=$(find "$WORK/var/lib/mike-ai-disaster-backup/latest" \
|
||||||
|
-maxdepth 1 -name docker-state.tar.gz -type f -print -quit 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
[[ -s $state ]] || die "Docker-Zustandsarchiv fehlt im Backup."
|
||||||
|
/opt/mike-ai/stack/restore.sh --check "$state"
|
||||||
|
/opt/mike-ai/stack/restore.sh "$state"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_bootstrap_packages
|
||||||
|
if [[ $SCENARIO == system ]]; then
|
||||||
|
restore_local_bootstrap
|
||||||
|
elif [[ -n $PORTABLE ]]; then
|
||||||
|
restore_portable_archive
|
||||||
|
else
|
||||||
|
restore_external_snapshot
|
||||||
|
fi
|
||||||
|
|
||||||
|
# In the data-only case the source/configuration remain on the system disk.
|
||||||
|
[[ -x /opt/mike-ai/stack/install.sh ]] || die "/opt/mike-ai/stack fehlt."
|
||||||
|
run_installer
|
||||||
|
restore_docker_state
|
||||||
|
/opt/mike-ai/stack/platform/recovery/rebuild-specialized.sh
|
||||||
|
/opt/mike-ai/stack/smoke-test.sh
|
||||||
|
|
||||||
|
printf '\nATHENA_DISASTER_RECOVERY_OK scenario=%s\n' "$SCENARIO"
|
||||||
|
printf 'Athena läuft im LLM-Standardmodus; Spezial-GPU-Worker bleiben gestoppt.\n'
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# 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. Während des exklusiven GPU-Wechsels ist TTS vorübergehend nicht verfügbar.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Die lokalen 4B-Gewichte und die kurzfristigen Rückfall-Images wurden am
|
||||||
|
8. September 2026 nach erfolgreicher 9B-Abnahme gezielt entfernt. Ein Rollback
|
||||||
|
auf 4B ist deshalb weiterhin reproduzierbar, aber nicht mehr unmittelbar: Das
|
||||||
|
4B-Modell muss erneut geladen und die ältere Stack-Fassung neu gebaut werden.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
# Qwen Beta 1 – GSQ-RCO
|
# Qwen Beta 1 – GSQ-RCO
|
||||||
|
|
||||||
`qwen-beta-1` ist ein zusätzliches, nicht standardmäßig aktives Router-Profil.
|
> Historischer Testbericht. Das Beta-1-Profil wurde am 10. September 2026
|
||||||
Die bestehenden Profile und das Standardprofil `qwen-medium` bleiben unverändert.
|
> vollständig aus dem produktiven Router entfernt, weil die kleinere
|
||||||
|
> Quantisierung gegenüber den Q4-Profilen keinen belastbaren Vorteil brachte.
|
||||||
|
|
||||||
|
`qwen-beta-1` war ein zusätzliches, nicht standardmäßig aktives Router-Profil.
|
||||||
|
Der Bericht bleibt erhalten, damit diese Quantisierung nicht versehentlich
|
||||||
|
erneut getestet wird.
|
||||||
|
|
||||||
## Laufzeitkonfiguration
|
## Laufzeitkonfiguration
|
||||||
|
|
||||||
- Modell: Qwen3.8-27B GSQ-RCO IQ3_XXS MTP
|
- Modell: Qwen3.8-27B GSQ-RCO IQ3_S MTP
|
||||||
- Kontext: 192.000 Token
|
- Kontext: 112.000 Token als konservativer Startwert
|
||||||
- Textmodell und KV-Cache: vollständig RTX 5080
|
- Textmodell und KV-Cache: vollständig RTX 5080
|
||||||
- Vision-Projektor: RTX 3060
|
- Vision-Projektor: RTX 3060
|
||||||
- KV-Quantisierung: Q4_0 für K und V
|
- KV-Quantisierung: Q4_0 für K und V
|
||||||
- MTP: 3 Draft-Token
|
- MTP: 3 Draft-Token
|
||||||
- Batch / Micro-Batch: 2048 / 128
|
- Batch / Micro-Batch: 2048 / 128
|
||||||
|
|
||||||
## Gemessene Kontextgrenze
|
## Vorherige IQ3_XXS-Kontextgrenze
|
||||||
|
|
||||||
Eine echte Bildanfrage mit einer 2,3-MB-JPEG-Datei wurde zur Bestimmung der
|
Eine echte Bildanfrage mit einer 2,3-MB-JPEG-Datei wurde zur Bestimmung der
|
||||||
VRAM-Grenze verwendet.
|
VRAM-Grenze verwendet.
|
||||||
@@ -24,9 +29,120 @@ VRAM-Grenze verwendet.
|
|||||||
| 196.608 | bestanden, harte Kante | ca. 9 MiB |
|
| 196.608 | bestanden, harte Kante | ca. 9 MiB |
|
||||||
| 197.120 | CUDA Out of Memory | ca. 1 MiB vor Abbruch |
|
| 197.120 | CUDA Out of Memory | ca. 1 MiB vor Abbruch |
|
||||||
|
|
||||||
Der produktive Beta-Modus verwendet deshalb 192.000 Token. 196.608 ist nur
|
Diese Werte gelten ausschließlich für die frühere, kleinere
|
||||||
die gemessene technische Obergrenze und besitzt keine ausreichende Reserve
|
`IQ3_XXS-MTP`-Datei. Sie dürfen nicht als Grenze der größeren
|
||||||
für einen verlässlichen Dauerbetrieb.
|
`IQ3_S-MTP`-Datei interpretiert werden. Das neue Profil startet bei 112.000
|
||||||
|
Token; seine technische und betrieblich sichere Grenze wird neu vermessen.
|
||||||
|
|
||||||
Beim erfolgreichen 196.608-Test erreichte die Bildanfrage rund 366 Prompt-
|
Beim erfolgreichen 196.608-Test erreichte die Bildanfrage rund 366 Prompt-
|
||||||
Token/s und 85 Ausgabe-Token/s. Das erkannte Bild wurde korrekt beschrieben.
|
Token/s und 85 Ausgabe-Token/s. Das erkannte Bild wurde korrekt beschrieben.
|
||||||
|
|
||||||
|
## Austausch am 08.09.2026
|
||||||
|
|
||||||
|
Die bisherige `IQ3_XXS-MTP`-Datei wurde durch `IQ3_S-MTP` ersetzt. ISTA
|
||||||
|
berichtet für die 3,5-bpw-Variante gegenüber BF16 identische Ergebnisse auf
|
||||||
|
AIME25 und LiveCodeBench v6 sowie 0,51 Punkte Abstand auf GPQA-Diamond. Diese
|
||||||
|
Herstellermessungen rechtfertigen den A/B-Test, ersetzen aber keine lokale
|
||||||
|
Prüfung mit Hermes-, Werkzeug- und Langkontextaufgaben.
|
||||||
|
|
||||||
|
## Lokaler A/B-Test am 08.09.2026
|
||||||
|
|
||||||
|
Beide Dateien liefen mit 112.000 Kontext, Q4_0-K/V-Cache, MTP 3, identischem
|
||||||
|
Sampling und einem 85:15-Layer-Split über RTX 5080 und RTX 3060.
|
||||||
|
|
||||||
|
| Messung | IQ4_XS Pure | GSQ-RCO IQ3_S MTP |
|
||||||
|
|---|---:|---:|
|
||||||
|
| deterministische Kurzaufgaben | 24/25 | 24/25 |
|
||||||
|
| Decode, 512 Token | 65,6 Token/s | 59,3 Token/s |
|
||||||
|
| Prefill, 30 Token | 184,5 Token/s | 251,6 Token/s |
|
||||||
|
|
||||||
|
Beide Modelle machten denselben einzelnen Fehler bei `2^100 modulo 13`. Im
|
||||||
|
lokalen Kurztest war damit kein Qualitätsverlust der neuen Quantisierung
|
||||||
|
messbar. Der kurze Prefill-Wert ist nur ein Laufzeitindikator und kein
|
||||||
|
Langkontext-Benchmark.
|
||||||
|
|
||||||
|
In der produktiven Beta-1-Verteilung liegt das komplette Textmodell auf der
|
||||||
|
RTX 5080 und nur der Vision-Projektor auf der RTX 3060. Dort wurden 89,9
|
||||||
|
Token/s Decode gemessen; nach dem Lauf blieben etwa 1.051 MiB auf der RTX 5080
|
||||||
|
frei. Ein realer Bildtest beschrieb Motiv und sichtbaren Text korrekt. Das
|
||||||
|
Profil war anschließend gesund. Die frühere IQ3_XXS-GGUF wurde erst nach diesen
|
||||||
|
Prüfungen entfernt; die JSON-Ergebnisse liegen auf Athena unter
|
||||||
|
`/data/model-benchmarks/gsq-rco-iq3s-ab-20260908/`.
|
||||||
|
|
||||||
|
## Profilweiter A/B-Härtetest am 08.09.2026
|
||||||
|
|
||||||
|
Ein zweiter Test verglich GSQ-RCO IQ3_S mit den jeweils heute verwendeten
|
||||||
|
Q4-Modellen unter den echten Kontext-, GPU-, MTP- und Batch-Einstellungen der
|
||||||
|
Profile. Medium und Large luden dabei auch den Vision-Projektor auf der RTX
|
||||||
|
3060; der dort bereits laufende TTS-Dienst blieb unangetastet. Alle acht
|
||||||
|
Varianten fanden drei synthetische Nadeln bei 70 Prozent des jeweiligen
|
||||||
|
Kontextfensters.
|
||||||
|
|
||||||
|
| Profil | Q4 kurzer Prefill | IQ3_S kurzer Prefill | Delta | Q4 Decode | IQ3_S Decode | Delta | Q4 Lang-Prefill | IQ3_S Lang-Prefill | Delta | Q4 Lang-Decode | IQ3_S Lang-Decode | Delta |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| Fast 76,8K | 938,2 | 860,5 | -8,3 % | 115,3 | 109,2 | -5,2 % | 816,7 | 757,4 | -7,3 % | 74,7 | 76,4 | +2,3 % |
|
||||||
|
| Medium 160K | 1.449,7 | 1.342,3 | -7,4 % | 104,0 | 86,9 | -16,5 % | 948,3 | 897,3 | -5,4 % | 56,3 | 48,9 | -13,1 % |
|
||||||
|
| Large 192K | 1.456,5 | 1.342,6 | -7,8 % | 104,3 | 86,9 | -16,7 % | 849,5 | 808,9 | -4,8 % | 52,2 | 45,5 | -12,8 % |
|
||||||
|
| Ultra 262K | 1.728,3 | 1.288,2 | -25,5 % | 83,8 | 73,5 | -12,3 % | 808,2 | 677,8 | -16,1 % | 33,3 | 32,0 | -4,0 % |
|
||||||
|
|
||||||
|
Alle Geschwindigkeiten sind Token/s. `Fast` vergleicht das produktive
|
||||||
|
IQ4-MIX mit IQ3_S; die übrigen Profile vergleichen IQ4_XS Pure mit IQ3_S.
|
||||||
|
Die langen Prompts enthielten rund 53,8K, 112K, 134,5K beziehungsweise 183,6K
|
||||||
|
synthetische Token.
|
||||||
|
|
||||||
|
Der komplexere Qualitätstest bestand aus neun deutschsprachigen Aufgaben zu
|
||||||
|
Logik, evidenzgebundener Diagnose, nebenläufigem Python, Kapazitätsplanung,
|
||||||
|
Prompt-Injection, Laufzeit- gegenüber Konfigurationszustand und sicherem
|
||||||
|
Adminverhalten sowie einem nativen Tool-Call. Acht Aufgaben waren inhaltlich
|
||||||
|
gleichwertig; beide Modelle hatten beim nebenläufigen Python-Code denselben
|
||||||
|
subtilen Restfehler. Bei der Kapazitätsplanung ermittelten beide intern korrekt,
|
||||||
|
dass die Migration unmöglich ist. Beide erreichten jedoch das 4K-Ausgabelimit:
|
||||||
|
Q4 gab die Schlussfolgerung und fast den ganzen Beweis sichtbar aus, IQ3_S
|
||||||
|
verbrauchte das Limit vollständig im Reasoning und lieferte keinen sichtbaren
|
||||||
|
Antworttext. Beide nativen Tool-Calls waren korrekt.
|
||||||
|
|
||||||
|
Über alle neun Aufgaben benötigte Q4 223,0 Sekunden und IQ3_S 278,9 Sekunden;
|
||||||
|
IQ3_S war damit 25,0 Prozent länger beschäftigt. Zusammen mit der überwiegend
|
||||||
|
niedrigeren Inferenzgeschwindigkeit ist kein profilweiter Vorteil belegt.
|
||||||
|
|
||||||
|
Entscheidung: Die produktiven Q4-Profile werden nicht durch IQ3_S ersetzt und
|
||||||
|
es werden keine vollständigen Q3-Doppelprofile angelegt. `beta1` bleibt als
|
||||||
|
gezielter 112K-Versuch erhalten: Dort passt das gesamte Textmodell auf die RTX
|
||||||
|
5080, während der Projektor auf der RTX 3060 liegt. Dieser besondere
|
||||||
|
Platzierungsvorteil gilt nicht automatisch für die größeren Profile.
|
||||||
|
|
||||||
|
Die vollständigen JSON-Ergebnisse liegen auf Athena unter
|
||||||
|
`/data/model-benchmarks/gsq-rco-iq3s-ab-v2-20260908/`.
|
||||||
|
|
||||||
|
## Nachtest mit maximaler RTX-5080-Belegung am 08.09.2026
|
||||||
|
|
||||||
|
Der vorige Vergleich übernahm absichtlich die produktiven Q4-Tensor-Splits.
|
||||||
|
Dadurch nutzte IQ3_S seinen geringeren Platzbedarf nicht aus. In einem weiteren
|
||||||
|
reinen Geschwindigkeitstest wurde deshalb pro Profil der größtmögliche unter
|
||||||
|
echter Last stabile Anteil auf der RTX 5080 gesucht. TTS blieb auf der RTX 3060
|
||||||
|
geladen. Ein Split galt erst dann als stabil, wenn Modellstart, kurzer Test und
|
||||||
|
ein Prompt mit rund 70 Prozent des Kontextfensters vollständig durchliefen.
|
||||||
|
|
||||||
|
| Profil | stabiler IQ3_S-Split 5080:3060 | kurzer Prefill vs. Q4 | Decode vs. Q4 | Lang-Prefill vs. Q4 | Lang-Decode vs. Q4 |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| Medium 160K | 96:4 | +1,1 % | -8,2 % | +2,9 % | -2,8 % |
|
||||||
|
| Large 192K | 96:4 | +0,8 % | -8,3 % | +1,9 % | -2,1 % |
|
||||||
|
| Ultra 262K | 88:12 | -20,1 % | -4,5 % | -12,4 % | +3,2 % |
|
||||||
|
|
||||||
|
Medium lief mit 96:4 stabil. 98:2 ließ sich zwar laden, stürzte jedoch beim
|
||||||
|
ersten langen Prompt ab; 99:1 scheiterte bereits beim Laden. Large lief mit
|
||||||
|
96:4 stabil, während 97:3 beim Laden des MTP-KV-Caches keinen ausreichenden
|
||||||
|
VRAM mehr hatte. Ultra lief mit 88:12 stabil. 92:8 und 90:10 ließen sich laden,
|
||||||
|
stürzten aber beim langen Prompt ab; 94:6 scheiterte bereits an den benötigten
|
||||||
|
Compute-Puffern. Die scheinbar nicht streng monotone Belegung entsteht durch
|
||||||
|
die diskrete Verteilung ganzer Tensoren beziehungsweise Layer und zusätzliche
|
||||||
|
KV-, MTP- und Compute-Puffer.
|
||||||
|
|
||||||
|
Alle drei stabilen Grenzläufe fanden erneut sämtliche drei Nadeln. Das stärkere
|
||||||
|
Ausreizen der RTX 5080 macht IQ3_S bei Medium und Large im Prefill knapp
|
||||||
|
schneller, beseitigt den Decode-Nachteil aber nicht. Bei Ultra steht einem
|
||||||
|
kleinen Vorteil von 3,2 Prozent im langen Decode ein deutlicher
|
||||||
|
Prompt-Verarbeitungsverlust gegenüber. Auch nach optimaler Platzierung ergibt
|
||||||
|
sich daher kein Geschwindigkeitsgrund, die produktiven Q4-Profile zu ersetzen.
|
||||||
|
Die optimierten JSON-Ergebnisse liegen im selben Benchmark-Verzeichnis und
|
||||||
|
tragen das Suffix `opt96-4` beziehungsweise `opt88-12`.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Athena-Betriebsmodi
|
||||||
|
|
||||||
|
Athena besitzt sechs gegenseitig exklusive Betriebsmodi:
|
||||||
|
|
||||||
|
- `llm`: ein llama.cpp-Profil und Qwen3-TTS laufen; Spezialdienste sind gestoppt.
|
||||||
|
- `music`: ACE-Step 1.5 XL-SFT läuft; alle LLM-, Bild-, TTS- und Separator-Worker sind gestoppt.
|
||||||
|
- `separation`: BS-RoFormer und Demucs trennen Musikspuren; ClearVoice trennt
|
||||||
|
Sprache von Hintergrundgeräuschen. LLM, Bild, TTS und ACE-Step sind gestoppt.
|
||||||
|
- `voice`: OmniVoice erzeugt Sprache aus Text mit einer gewählten
|
||||||
|
Referenzstimme. LLM, Bild, TTS, ACE-Step und Separator sind gestoppt.
|
||||||
|
- `voicechange`: X-VC überträgt eine vorhandene Sprachaufnahme auf eine
|
||||||
|
Referenzstimme und bewahrt dabei Inhalt und Timing. Alle anderen
|
||||||
|
GPU-Dienste sind gestoppt.
|
||||||
|
- `applio`: Applio stellt RVC-Inferenz, Modellverwaltung und Training bereit.
|
||||||
|
Alle anderen GPU-Dienste sind gestoppt.
|
||||||
|
|
||||||
|
Die Zustandsmaschine lebt im Athena-Router. Das Dashboard und Chat-Clients wie
|
||||||
|
Hermes sind nur Bedienoberflächen derselben API. Der zuletzt aktive LLM-Modus
|
||||||
|
wird persistent gespeichert und beim Verlassen eines Spezialmodus wieder geladen.
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
Im Athena-Dashboard stehen **LLM-Betrieb**, **Musikstudio**, **Audio trennen**,
|
||||||
|
**Voice Studio**, **X-VC** und **Applio / RVC** bereit. Im Musikmodus werden zwei Oberflächen angeboten:
|
||||||
|
|
||||||
|
- **Original UI · stabil** öffnet die zum laufenden ACE-Step-Image gehörende
|
||||||
|
Gradio-Oberfläche. Sie ist für Cover, Remix und erweiterte Workflows der
|
||||||
|
verbindliche Produktionspfad.
|
||||||
|
- **Community UI · experimentell** öffnet `fspecii/ace-step-ui`. Die
|
||||||
|
CPU-leichte React/Express-Anwendung hält Bibliothek, Playlists und
|
||||||
|
Einstellungen in `/data/music/ace-step-ui`. Ein noch nicht übernommener
|
||||||
|
Upstream-Kompatibilitätsfix für die aktuelle 72-Felder-Gradio-API ist lokal
|
||||||
|
zurückportiert; normale Generierung funktioniert, Cover und Remix gelten bis
|
||||||
|
zu eigenen Ende-zu-Ende-Tests weiterhin als experimentell.
|
||||||
|
|
||||||
|
Die Community-Oberfläche ist im WireGuard-Netz unter
|
||||||
|
`http://192.168.1.212:7861`, die originale Gradio-Oberfläche unter
|
||||||
|
`http://192.168.1.212:7862` erreichbar. Beide Host-Ports bleiben zusätzlich
|
||||||
|
auf `127.0.0.1` gebunden und werden auf der Universitäts-Schnittstelle nicht
|
||||||
|
veröffentlicht. `ace-step-ui` ist reproduzierbar auf Commit
|
||||||
|
`a1fdf91829ec6f7b98844f80e323529cd155dbf2` fixiert und greift intern über das
|
||||||
|
Docker-Netz `mike-ai-music` auf `http://music-worker:7860` zu.
|
||||||
|
|
||||||
|
Im Trennmodus öffnet das Dashboard die private Athena-Oberfläche unter
|
||||||
|
`http://192.168.1.212:8007`. Sie nimmt WAV, FLAC, MP3, M4A und weitere
|
||||||
|
übliche Formate an. Gewählt wird die herauszulösende Quelle: Gesang,
|
||||||
|
Schlagzeug, Bass, Gitarre, Piano, Sonstiges oder gereinigte Sprache. Das ZIP enthält genau diese Zielspur und
|
||||||
|
eine zweite FLAC-Datei mit dem vollständigen Rest ohne die Zielspur. Gesang
|
||||||
|
nutzt BS-RoFormer Viperx 1297, Schlagzeug/Bass `htdemucs_ft` und
|
||||||
|
Gitarre/Piano/Sonstiges experimentell `htdemucs_6s`. „Sonstiges“ ist dessen
|
||||||
|
gemischter `other`-Stem (unter anderem Synthesizer, Streicher, Bläser und Effekte),
|
||||||
|
nicht eine reine Synthesizer-Spur. Sprache nutzt das 48-kHz-Modell
|
||||||
|
`MossFormer2_SE_48K`; der Download enthält `speech.flac` und
|
||||||
|
`hintergrund-ohne-sprache.flac`. Die Musiktrennung basiert auf
|
||||||
|
`audio-separator` 0.47.0. Die ältere API-Auswahl kompletter 2-/4-/6-Stem-Sätze
|
||||||
|
bleibt rückwärtskompatibel.
|
||||||
|
|
||||||
|
Das Voice Studio ist ausschließlich über den privaten WireGuard-Pfad unter
|
||||||
|
`http://192.168.1.212:8008` erreichbar. Referenzstimmen werden unter
|
||||||
|
`/data/voice/studio/profiles` gespeichert. Die Oberfläche verlangt vor dem
|
||||||
|
Speichern eine Bestätigung der Nutzungsberechtigung. OmniVoice gibt
|
||||||
|
unkomprimiertes WAV aus und erzeugt Sprache aus Text; es verarbeitet keine
|
||||||
|
bereits eingesprochene Quellaufnahme.
|
||||||
|
|
||||||
|
Der X-VC Voice Changer ist ausschließlich unter
|
||||||
|
`http://192.168.1.212:8009` erreichbar. Er nimmt eine Quellaufnahme und eine
|
||||||
|
Referenzstimme an. Die Oberfläche behält immer das native 16-kHz-PCM-WAV und
|
||||||
|
erzeugt auf Wunsch zusätzlich mit Resemble Enhance eine neural restaurierte
|
||||||
|
44,1-kHz-Fassung. Diese zweite Datei rekonstruiert fehlende Sprachbandbreite;
|
||||||
|
sie stellt keine im 16-kHz-Signal tatsächlich erhaltenen Originaldetails wieder
|
||||||
|
her und bleibt deshalb direkt mit dem nativen Ergebnis vergleichbar. Die dokumentierte Sprachbasis
|
||||||
|
des verwendeten GLM-4-Voice-Tokenizers ist Chinesisch und Englisch; Deutsch
|
||||||
|
bleibt deshalb bis zur Hörabnahme ein Qualitätstest und kein zugesagter
|
||||||
|
Produktionspfad. X-VC läuft ausschließlich auf der RTX 5080.
|
||||||
|
|
||||||
|
Applio ist unter `http://192.168.1.212:8011` erreichbar. Der RVC-Pfad besitzt
|
||||||
|
eine eigene Modellbibliothek, Inferenz und Training. Hochwertige Inferenz
|
||||||
|
benötigt zwingend ein zuvor importiertes oder trainiertes RVC-Stimmenmodell
|
||||||
|
(`.pth`, optional `.index`). Eine bloße Referenzaufnahme genügt bei Applio
|
||||||
|
nicht. Der Code ist auf Commit
|
||||||
|
`7fa68ec2166ab1331c539704159fa14901e94e5a` fixiert.
|
||||||
|
|
||||||
|
Hermes benötigt dafür kein Plugin. Exakt eingegebene Steuerbefehle werden vom
|
||||||
|
Router lokal beantwortet, auch wenn gerade kein LLM geladen ist:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/athena music
|
||||||
|
/athena stems
|
||||||
|
/athena voice
|
||||||
|
/athena voicechange
|
||||||
|
/athena applio
|
||||||
|
/athena llm
|
||||||
|
/athena status
|
||||||
|
```
|
||||||
|
|
||||||
|
Die HTTP-Schnittstelle verwendet authentifizierte Requests:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /mode
|
||||||
|
POST /mode {"mode":"music"}
|
||||||
|
POST /mode {"mode":"separation"}
|
||||||
|
POST /mode {"mode":"voice"}
|
||||||
|
POST /mode {"mode":"voicechange"}
|
||||||
|
POST /mode {"mode":"applio"}
|
||||||
|
POST /mode {"mode":"llm"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Wechsel läuft asynchron. Fortschritt und Fehler stehen unter `mode` in
|
||||||
|
`GET /status`. Der Profile-Controller akzeptiert ausschließlich den mit
|
||||||
|
`com.mike-ai.music-worker=acestep` beziehungsweise
|
||||||
|
`com.mike-ai.stem-separator=bs-roformer` oder
|
||||||
|
`com.mike-ai.voice-worker=vevo2` beziehungsweise
|
||||||
|
`com.mike-ai.voice-change-worker=xvc` oder
|
||||||
|
`com.mike-ai.applio-worker=applio` markierten Container; freie
|
||||||
|
Container- oder Docker-Befehle werden nicht entgegengenommen.
|
||||||
|
|
||||||
|
## Wiederanlauf
|
||||||
|
|
||||||
|
Der Router speichert `mode`, `last_profile` und `return_profile` atomar. War
|
||||||
|
beim Router-Neustart ein Spezialmodus aktiv, startet er den passenden Worker erneut. Beim
|
||||||
|
Wechsel zurück wird das gespeicherte LLM-Profil semantisch auf Alias und
|
||||||
|
Kontextfenster geprüft, bevor Chat-Anfragen wieder freigegeben werden.
|
||||||
@@ -8,7 +8,6 @@ Standardprofil: **medium** · globales Ausgabelimit: **8192 Token**
|
|||||||
|---|---|---:|---:|---|---|---|---:|
|
|---|---|---:|---:|---|---|---|---:|
|
||||||
| fast | `qwen-fast` | 76,800 | 1 | Qwen3.8-27B IQ4 Mix | 5080 only | ja | 2 |
|
| fast | `qwen-fast` | 76,800 | 1 | Qwen3.8-27B IQ4 Mix | 5080 only | ja | 2 |
|
||||||
| medium | `qwen-medium` | 160,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 85:15 | ja | 3 |
|
| medium | `qwen-medium` | 160,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 85:15 | ja | 3 |
|
||||||
| beta1 | `qwen-beta-1` | 192,000 | 1 | Qwen3.8-27B GSQ-RCO IQ3_XXS MTP | 5080 model / 3060 vision | ja | 3 |
|
|
||||||
| large | `qwen-large` | 192,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 86:14 | ja | 3 |
|
| large | `qwen-large` | 192,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 86:14 | ja | 3 |
|
||||||
| ultra | `qwen-ultra` | 262,144 | 1 | Qwen3.8-27B IQ4 XS Pure | 80:20 | nein | 2 |
|
| ultra | `qwen-ultra` | 262,144 | 1 | Qwen3.8-27B IQ4 XS Pure | 80:20 | nein | 2 |
|
||||||
| uncensored | `qwen-uncensored` | 80,000 | 1 | Qwen3.8-27B Abliterated Q4_K_M | 90:10 | ja | 2 |
|
| uncensored | `qwen-uncensored` | 80,000 | 1 | Qwen3.8-27B Abliterated Q4_K_M | 90:10 | ja | 2 |
|
||||||
@@ -17,7 +16,6 @@ Standardprofil: **medium** · globales Ausgabelimit: **8192 Token**
|
|||||||
|
|
||||||
- **fast**: Schnelles Profil für kurze Chats und zügige Werkzeugaufgaben.
|
- **fast**: Schnelles Profil für kurze Chats und zügige Werkzeugaufgaben.
|
||||||
- **medium**: Ausgewogenes Standardprofil für Alltag und lange agentische Aufgaben.
|
- **medium**: Ausgewogenes Standardprofil für Alltag und lange agentische Aufgaben.
|
||||||
- **beta1**: Beta 1: schnelles GSQ-RCO-Testprofil mit 192K Kontext und Vision-Projektor auf der RTX 3060.
|
|
||||||
- **large**: Großes Profil für umfangreiche Dokumente und lange technische Arbeiten.
|
- **large**: Großes Profil für umfangreiche Dokumente und lange technische Arbeiten.
|
||||||
- **ultra**: Maximaler Textkontext; bewusst ohne Vision-Projektor.
|
- **ultra**: Maximaler Textkontext; bewusst ohne Vision-Projektor.
|
||||||
- **uncensored**: Weniger restriktives Spezialprofil; Werkzeugrechte bleiben unverändert.
|
- **uncensored**: Weniger restriktives Spezialprofil; Werkzeugrechte bleiben unverändert.
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Register getesteter Modelle
|
||||||
|
|
||||||
|
Stand: 9. September 2026
|
||||||
|
|
||||||
|
Dieses Dokument ist die zentrale Sperrliste gegen doppelte Modelltests. Vor
|
||||||
|
jedem Download müssen Repository, Dateiname, Basismodell, Fine-Tune und
|
||||||
|
Quantisierung hier geprüft werden. Unterschiedliche Quantisierungen desselben
|
||||||
|
Basismodells gelten als eigene Kandidaten.
|
||||||
|
|
||||||
|
Statuswerte:
|
||||||
|
|
||||||
|
- **produktiv**: wird von mindestens einem regulären Profil verwendet
|
||||||
|
- **Beta**: bleibt gezielt verfügbar, ersetzt aber nicht den Standard
|
||||||
|
- **verworfen**: getestet und ohne ausreichenden Gesamtvorteil
|
||||||
|
- **ersetzt**: früher genutzt oder getestet, inzwischen abgelöst
|
||||||
|
- **unvollständig**: Artefakt vorbereitet, aber kein belastbarer Abnahmetest
|
||||||
|
|
||||||
|
## Textmodelle auf Athena
|
||||||
|
|
||||||
|
| Datum | Exaktes Modell beziehungsweise Artefakt | Kontext im Test | Ergebnis | Status / Entscheidung | Beleg |
|
||||||
|
|---|---|---:|---|---|---|
|
||||||
|
| 22.08.2026 | `jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF` / `qwen3.8-27b-IQ4_XS-pure.gguf` | 160K–262K | beste ausgewogene Q4-Referenz; Langkontext, Tool-Call und Vision geprüft | **produktiv** für Medium, Large und Ultra | `benchmarks/qwen38-final-pre-move-20260822/` |
|
||||||
|
| 22.08.2026 | `vmarcelo/Qwen3.8-27B-MIX_GGUF` / `Qwen3.8-27B-IQ4-MIX.gguf` | 76,8K | schnellstes vollständig auf der RTX 5080 liegendes Q4-Profil | **produktiv** für Fast | `benchmarks/qwen38-final-pre-move-20260822/` |
|
||||||
|
| 22.08.2026 | Qwen3.8-27B NVFP4 `Q4_K_M` mit eingebettetem beziehungsweise separatem MTP | 72K | eingebettete Variante scheiterte beim Laden; Split-MTP lief, bot aber keinen ausreichenden Vorteil | **verworfen** | `benchmarks/qwen38-final-pre-move-20260822/qwen38-final-acceptance-20260822/` |
|
||||||
|
| 22.08.2026 | `Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF` / `Q4_K_M` | 80K | stabiler Spezialpfad mit Vision und MTP2 | **produktiv** für Uncensored | `benchmarks/qwen38-final-pre-move-20260822/qwen38-abliterated-final-20260822/` |
|
||||||
|
| 01.09.2026 | `peculiar-ragdoll/Dirk-Qwen3.8-27B-GGUF` / `UD-Q4_K_XL` | 80K–262K | korrekt und teils knapper, bei 160K aber 29–38 % langsamer im Decode als Pure | **verworfen** | [DIRK_QWEN38_AB_20260901.md](DIRK_QWEN38_AB_20260901.md) |
|
||||||
|
| 04.09.2026 | ISTA-DASLab Qwen3.8-27B GSQ-RCO `IQ3_XXS-MTP` | bis 196.608 | sehr platzsparend und bis 196.608 technisch lauffähig; später durch IQ3_S ersetzt | **ersetzt** | [GSQ_RCO_BETA1_20260904.md](GSQ_RCO_BETA1_20260904.md) |
|
||||||
|
| 07.09.2026 | `Jackrong/Qwopus3.8-27B-Flash-GGUF` / `Qwopus3.8-27B-Flash-MTP-IQ4_XS.gguf` | 160K | Recall 3/3; Decode 87,2 statt 105,3 Token/s, Lang-Decode 51,0 statt 56,7 Token/s; kein Gesamtvorteil | **verworfen** | Athena: `/data/benchmarks/qwen38-ab-20260907/` |
|
||||||
|
| 07.09.2026 | `bartowski/Qwen3.8-27B-GGUF` / `Qwen3.8-27B-IQ4_XS.gguf` | 160K | Recall 3/3; Decode 90,4 statt 105,3 Token/s, Lang-Decode 51,9 statt 56,7 Token/s; kein Gesamtvorteil | **verworfen** | Athena: `/data/benchmarks/qwen38-ab-20260907/` |
|
||||||
|
| 08.09.2026 | `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF` / `IQ3_S-MTP` | 76,8K–262K | Qualität im lokalen Test praktisch gleich, trotz optimierter GPU-Splits überwiegend langsamer als Q4 | **entfernt**; kein Ersatz für Q4 | [GSQ_RCO_BETA1_20260904.md](GSQ_RCO_BETA1_20260904.md) |
|
||||||
|
| 08.09.2026 | `Tiel-Coder-35B-A3B-UD-IQ4_XS.gguf` | 160K vorgesehen | Testcontainer und Gewichte vorhanden gewesen, aber kein versionierter, belastbarer Abnahmebericht | **unvollständig**; nicht als getesteter Sieger behandeln | kein Ergebnisartefakt vorhanden |
|
||||||
|
|
||||||
|
## Externe CPU-Helfermodelle
|
||||||
|
|
||||||
|
Diese Versuche liefen nicht als Athena-Hauptprofil, sind aber relevant für
|
||||||
|
Titelgenerierung und Kontextkompression in Hermes.
|
||||||
|
|
||||||
|
| Datum | Modell | Beobachtung | Entscheidung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 06.09.2026 | Ollama `qwen3:8b` | ungefähr 8,5–9,2 Token/s auf dem alten Dual-Xeon-Server | technisch brauchbar, aber für synchrone Hermes-Hilfsaufrufe langsam |
|
||||||
|
| 06.09.2026 | Ollama `gemma4:e4b` Q4 | ungefähr 4,7 Token/s auf dem HP EliteDesk, 10,2 auf dem alten Dual-Xeon und 15,1 auf dem neueren Proxmox-Host; mit Thinking liefen Hilfsaufrufe in Hermes in den 30-s-Timeout | nur mit `think:false` sinnvoll; nicht produktiv als Hermes-Auxiliary belegt |
|
||||||
|
|
||||||
|
## Bildmodelle und Restaurierung
|
||||||
|
|
||||||
|
| Datum | Modell | Ergebnis | Status / Entscheidung | Beleg |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| bis 07.09.2026 | FLUX.2 Klein 4B | funktional, aber schwächere räumliche und motivische Konsistenz | **ersetzt** durch 9B FP8 | [FLUX_9B_BETA.md](FLUX_9B_BETA.md) |
|
||||||
|
| 07.09.2026 | FLUX.2 Klein 9B FP8 | bessere Prompttreue; produktiver Zwei-GPU-Pfad, derzeit auf 1024 × 1024 begrenzt | **produktiv als Beta** | [FLUX_9B_BETA.md](FLUX_9B_BETA.md) |
|
||||||
|
| 08.09.2026 | HYPIR-SD2 | glättete oder erfand Details und veränderte kleine Strukturen | **verworfen** | [IMAGE_RESTORATION.md](IMAGE_RESTORATION.md) |
|
||||||
|
| 08.09.2026 | SeedVR2 7B FP8 | bewahrte Identität besser als HYPIR, brachte beim realen unscharfen Foto aber kaum nutzbare Details zurück | **verworfen** | [IMAGE_RESTORATION.md](IMAGE_RESTORATION.md) |
|
||||||
|
|
||||||
|
## Sprache
|
||||||
|
|
||||||
|
| Datum | Modell | Ergebnis | Status / Entscheidung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 05.09.2026 | Coqui XTTS v2 | deutsche Satzzeichen, Enden und Streaming-Chunks erzeugten Halluzinationen und unnatürliche Prosodie | **verworfen und entfernt** |
|
||||||
|
| 05.09.2026 | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | deutlich natürlichere deutsche Ausgabe ohne die XTTS-Endhalluzinationen | **produktiv** als einziges TTS-Backend |
|
||||||
|
| 06.–08.09.2026 | Whisper.cpp `large-v3-turbo` | lokaler TTS→STT-Rundlauf und OpenClaw-Transkription erfolgreich; später zugunsten des kleineren Laufzeitmodells entfernt | **ersetzt** |
|
||||||
|
| seit 03.09.2026 | Whisper.cpp `ggml-small` | tatsächlich im Compose-Stack und im laufenden Container verwendetes CPU-STT-Modell | **produktiv** |
|
||||||
|
| 09.09.2026 | `RMSnow/Vevo2`, Amphion `26f6883110181f1dbfe95c70a7c7dbaf4de5f42a` | Technik und Geschwindigkeit funktionierten, reale deutsche Sprachwandlung mit kurzer und langer Referenz war jedoch unverständlich, halluzinierend oder musikalisch | **qualitativ verworfen und entfernt**; Ergebnis bleibt hier dokumentiert, Images, Daten und altes Projekt wurden am 09.09. bereinigt |
|
||||||
|
| 09.09.2026 | `k2-fsa/OmniVoice` 0.2.1 | Offizielle Gradio-UI auf RTX 5080 gestartet; Modell plus Whisper-ASR belegen rund 3,7 GiB VRAM. `omnivoice-triton` 0.1.0 ist kompatibel im Image vorhanden, für den ersten Hörtest aber bewusst noch nicht aktiviert | **technischer Starttest bestanden**, Hörabnahme und Basis-vs.-Triton-Messung offen; Gewichte CC BY-NC und daher nur nichtkommerziell einsetzen |
|
||||||
|
| 09.09.2026 | `chenxie95/X-VC`, Code `49df8c591eafc48b096e466d96f9839f9c0dd739`, UI-Basis `d761cd6421e85376b2656dfefd8471d7f35a42be` | Offizielles Beispiel Ende-zu-Ende gewandelt: 5,20 s Audio in 1,07 s (RTF 0,21), gültiges 16-kHz-Mono-PCM-WAV; Modell belegt rund 2,9 GiB auf der RTX 5080. GLM-4-Voice-Tokenizer dokumentiert Chinesisch und Englisch | **technischer Start- und Konvertierungstest bestanden**; deutsche Hörabnahme offen |
|
||||||
|
| 09.09.2026 | Resemble Enhance 0.0.1, Modellrevision `4e3510ce4a8391159f665903544c5150bee7b2cb` | 14,56 s native X-VC-Ausgabe bei 16 kHz wurden auf der RTX 5080 in 3,55 s zu 44,1-kHz-PCM-WAV restauriert. 3,27 % der gemessenen Signalenergie lagen danach oberhalb 8 kHz; damit ist der Pfad keine bloße Neuabtastung. Wegen der alten Upstream-Pins läuft die reine Inferenz mit NumPy 1.26.4/SciPy 1.11.4 auf dem bestehenden Torch-2.8/CUDA-12.8-Unterbau | **technisch produktiv als optionaler A/B-Pfad**; Hörabnahme entscheidet, ob die rekonstruierten Höhen subjektiv besser oder künstlicher klingen |
|
||||||
|
| 09.09.2026 | `Plachtaa/seed-vc` V1, Code `51383efd921027683c89e5348211d93ff12ac2a8` | Technisch vollständig lauffähig: gepinntes CUDA-Image, persistente Gewichte und reale WAV-Konvertierung mit etwa 3,6 GiB VRAM. Im deutschen Hörtest erhielt die Ausgabe jedoch einen deutlich chinesischen Akzent | **qualitativ verworfen und vollständig entfernt**; nicht erneut für deutsche Sprachwandlung einplanen |
|
||||||
|
| 09.09.2026 | `IAHispano/Applio`, Code `7fa68ec2166ab1331c539704159fa14901e94e5a` | Gepinntes CUDA-12.8-fähiges Image auf RTX 5080 gestartet; vollständige Applio/RVC-Oberfläche antwortet und CUDA ist verfügbar. Rund 1,8 GiB Basisgewichte und die Konfiguration wurden persistent ausgelagert. Es ist kein Zielstimmenmodell installiert; Applio kann aus einer Referenzaufnahme allein kein Modell ableiten | **technischer Start- und Persistenztest bestanden**; Konvertierung erst nach Import oder Training einer `.pth`-Stimme möglich |
|
||||||
|
|
||||||
|
## Musikgenerierung
|
||||||
|
|
||||||
|
| Datum | Modell | Test | Ergebnis | Status / Entscheidung | Beleg |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 08.09.2026 | `ACE-Step/acestep-v15-xl-sft` mit `acestep-5Hz-lm-1.7B`, offizielles ACE-Step-1.5-Image `sha256:95652cd780c78a1b1a7f6f0335530430f0ae53d96c7c12d59f9f39fa23d38567` | 30 s Instrumental, Thinking/LM aktiv, Batch 1, RTX 5080 16 GiB, automatischer CPU-Offload und INT8 Weight-only DiT | erfolgreich in 15,39 s: LM 8,00 s, DiT 7,39 s, MP3 0,82 s; PyTorch meldete maximal 9,38 GiB CUDA-Allokation; kein OOM/CUDA-Fehler | **Beta-Test bestanden**; Klangabnahme und Hermes-/Router-Integration noch offen | Athena: `/data/music/acestep/batch_1788873234/`; [SPECIALIZED_MODEL_ROADMAP.md](SPECIALIZED_MODEL_ROADMAP.md) |
|
||||||
|
|
||||||
|
## Audio-Trennung
|
||||||
|
|
||||||
|
| Datum | Modell | Test | Ergebnis | Status / Entscheidung | Beleg |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 08.09.2026 | BS-RoFormer Viperx 1297, `model_bs_roformer_ep_317_sdr_12.9755.ckpt`, `audio-separator` 0.47.0 | 20-s-FLAC eines vorhandenen ACE-Step-Titels, RTX 5080, CUDA 12.8, ONNX Runtime GPU 1.22.0 | zwei gültige FLAC-Spuren mit jeweils exakt 20,0 s; Verarbeitung 19 s; Vocal-Datei 1,45 MB, Instrumental-Datei 3,84 MB | **technischer Ende-zu-Ende-Test bestanden**; Hörabnahme durch Nutzer offen | [bs-roformer-vocal-separation](../experiments/bs-roformer-vocal-separation/README.md) |
|
||||||
|
|
||||||
|
## Ablauf für zukünftige Kandidaten
|
||||||
|
|
||||||
|
1. Exakten Hugging-Face-/Ollama-Namen und Dateinamen in diesem Dokument suchen.
|
||||||
|
2. Bei einem Treffer zuerst den vorhandenen Beleg lesen; kein erneuter Download
|
||||||
|
ohne einen konkret neuen Grund wie Runtime, Quantisierung oder Hardware.
|
||||||
|
3. Neue Tests isoliert gegen das aktuelle Produktionsmodell mit identischem
|
||||||
|
Kontext, KV-Cache, MTP, Sampling und Promptset ausführen.
|
||||||
|
4. Unmittelbar danach hier Datum, exaktes Artefakt, Ergebnis, Entscheidung und
|
||||||
|
Pfad zum Detailbericht ergänzen.
|
||||||
|
5. Verworfene Gewichte nach gesichertem Ergebnis wieder löschen.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM python:3.12-trixie
|
||||||
|
|
||||||
|
ARG APPLIO_COMMIT=7fa68ec2166ab1331c539704159fa14901e94e5a
|
||||||
|
ENV PATH=/app/.venv/bin:$PATH \
|
||||||
|
HF_HOME=/models/huggingface \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl ffmpeg git libportaudio2 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN git clone https://github.com/IAHispano/Applio.git . \
|
||||||
|
&& git checkout "$APPLIO_COMMIT" \
|
||||||
|
&& python3 -m venv /app/.venv \
|
||||||
|
&& pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir python-ffmpeg \
|
||||||
|
&& pip install --no-cache-dir torch==2.7.1 torchvision==0.22.1 torchaudio==2.7.1 \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu128 \
|
||||||
|
&& sed -i '/^torch==/d;/^torchvision==/d;/^torchaudio==/d' requirements.txt \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt \
|
||||||
|
&& pip install --no-cache-dir "websockets>=13.0"
|
||||||
|
|
||||||
|
EXPOSE 6969
|
||||||
|
CMD ["python3", "app.py", "--server-name", "0.0.0.0", "--port", "6969"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
services:
|
||||||
|
applio-studio:
|
||||||
|
build: .
|
||||||
|
image: mike-ai/applio-studio:7fa68ec
|
||||||
|
container_name: mike-ai-applio-studio
|
||||||
|
restart: "no"
|
||||||
|
# PyTorch DataLoader workers exchange training batches through /dev/shm.
|
||||||
|
# Docker's 64 MiB default is far too small and can make failed training
|
||||||
|
# runs look successful because of an upstream Applio exit-code bug.
|
||||||
|
shm_size: "16gb"
|
||||||
|
labels:
|
||||||
|
com.mike-ai.applio-worker: applio
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
HF_HOME: /models/huggingface
|
||||||
|
PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8011:6969"
|
||||||
|
volumes:
|
||||||
|
- /data/voice/applio/huggingface:/models/huggingface
|
||||||
|
- /data/voice/applio/logs:/app/logs
|
||||||
|
- /data/voice/applio/models:/app/rvc/models
|
||||||
|
- /data/voice/applio/datasets:/app/assets/datasets
|
||||||
|
- /data/voice/applio/config.json:/app/assets/config.json
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:6969/ >/dev/null"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 900s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
aliases: [applio-studio]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
name: mike-ai_frontend
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Qwen3.8 September 2026 A/B preparation
|
||||||
|
|
||||||
|
This directory prepares an isolated comparison of two Qwen3.8-27B IQ4_XS
|
||||||
|
artifacts without adding router profiles or changing the running Athena stack.
|
||||||
|
|
||||||
|
## Candidates
|
||||||
|
|
||||||
|
| ID | Artifact | Purpose | Pinned revision | Size |
|
||||||
|
| --- | --- | --- | --- | ---: |
|
||||||
|
| `qwopus` | `Jackrong/Qwopus3.8-27B-Flash-GGUF` / `Qwopus3.8-27B-Flash-MTP-IQ4_XS.gguf` | Efficiency fine-tune | `e146d61e88782677805b3b68ad3adf8674dde80d` | 15,420,445,792 B |
|
||||||
|
| `bartowski` | `bartowski/Qwen3.8-27B-GGUF` / `Qwen3.8-27B-IQ4_XS.gguf` | Standard Qwen, alternative IQ4_XS quant | `f0eec4a4bb4975114a030d048952d83c0a53c034` | 15,567,824,480 B |
|
||||||
|
|
||||||
|
The production reference remains
|
||||||
|
`jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF`. The candidates intentionally use the
|
||||||
|
same IQ4_XS quantization class so that the first comparison does not mix a
|
||||||
|
fine-tune difference with a quantization-class difference.
|
||||||
|
|
||||||
|
## Safety boundary
|
||||||
|
|
||||||
|
- Nothing in this directory is called by installation, Compose or the router.
|
||||||
|
- No production profile is added.
|
||||||
|
- Downloads happen only after explicitly running `download-candidate.sh`.
|
||||||
|
- `run-case.sh` refuses to start while a production `mike-ai-llama-*` model is
|
||||||
|
running. It never stops production itself.
|
||||||
|
- The test server binds to `127.0.0.1:5005` and is not exposed to the LAN.
|
||||||
|
- Cleanup is a dry run unless an explicit deletion flag is supplied. It only
|
||||||
|
addresses the exact test container, result directory and two pinned files.
|
||||||
|
|
||||||
|
## Later test sequence
|
||||||
|
|
||||||
|
Run these commands on Athena only after the active coding task has finished and
|
||||||
|
the GPUs have deliberately been released:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/mike-ai/stack/experiments/qwen38-20260907-ab
|
||||||
|
./inventory.sh
|
||||||
|
./download-candidate.sh qwopus
|
||||||
|
./download-candidate.sh bartowski
|
||||||
|
|
||||||
|
./run-case.sh qwopus 160000 85,15
|
||||||
|
./wait-ready.sh
|
||||||
|
./run-benchmark.sh qwopus-160k
|
||||||
|
./stop-case.sh
|
||||||
|
|
||||||
|
./run-case.sh bartowski 160000 85,15
|
||||||
|
./wait-ready.sh
|
||||||
|
./run-benchmark.sh bartowski-160k
|
||||||
|
./stop-case.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Only after both candidates pass the 160K quality and tool-call tests should
|
||||||
|
192K and 262144 be attempted. Promotion into the router is a separate decision
|
||||||
|
and is deliberately not implemented here.
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Preview everything owned by this experiment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cleanup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove only the test container and benchmark results:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cleanup.sh --results
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove only the two downloaded candidate files and their now-empty directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cleanup.sh --models
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove both:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cleanup.sh --all
|
||||||
|
```
|
||||||
|
|
||||||
|
The script never touches the production Pure, Mix, Beta 1 or uncensored model
|
||||||
|
directories.
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
candidate_config() {
|
||||||
|
case "${1:-}" in
|
||||||
|
qwopus)
|
||||||
|
CANDIDATE_ID=qwopus
|
||||||
|
REPOSITORY=Jackrong/Qwopus3.8-27B-Flash-GGUF
|
||||||
|
REVISION=e146d61e88782677805b3b68ad3adf8674dde80d
|
||||||
|
MODEL_FILE=Qwopus3.8-27B-Flash-MTP-IQ4_XS.gguf
|
||||||
|
MODEL_SHA256=88848920fd069ecfe509afd60d4b1f2192327f1edde5e10491a48f7f80c16fb8
|
||||||
|
MODEL_SIZE=15420445792
|
||||||
|
MODEL_ALIAS=qwen-qwopus-flash-ab
|
||||||
|
;;
|
||||||
|
bartowski)
|
||||||
|
CANDIDATE_ID=bartowski
|
||||||
|
REPOSITORY=bartowski/Qwen3.8-27B-GGUF
|
||||||
|
REVISION=f0eec4a4bb4975114a030d048952d83c0a53c034
|
||||||
|
MODEL_FILE=Qwen3.8-27B-IQ4_XS.gguf
|
||||||
|
MODEL_SHA256=c2ae2b018f967370087c196c86d6811b2340ec19138a3752252ade5fbd1f4786
|
||||||
|
MODEL_SIZE=15567824480
|
||||||
|
MODEL_ALIAS=qwen-bartowski-iq4-xs-ab
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Candidate must be qwopus or bartowski" >&2
|
||||||
|
return 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
MODEL_ROOT=${MODEL_ROOT:-/data/models/experiments/qwen38-ab-20260907}
|
||||||
|
MODEL_PATH="$MODEL_ROOT/$CANDIDATE_ID/$MODEL_FILE"
|
||||||
|
MODEL_URL="https://huggingface.co/$REPOSITORY/resolve/$REVISION/$MODEL_FILE"
|
||||||
|
}
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
MODE=${1:-preview}
|
||||||
|
MODEL_ROOT=${MODEL_ROOT:-/data/models/experiments/qwen38-ab-20260907}
|
||||||
|
RESULT_DIR=${RESULT_DIR:-/data/benchmarks/qwen38-ab-20260907}
|
||||||
|
NAME=mike-ai-llama-qwen38-ab
|
||||||
|
|
||||||
|
QWOPUS="$MODEL_ROOT/qwopus/Qwopus3.8-27B-Flash-MTP-IQ4_XS.gguf"
|
||||||
|
BARTOWSKI="$MODEL_ROOT/bartowski/Qwen3.8-27B-IQ4_XS.gguf"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
preview)
|
||||||
|
echo "Dry run only. Exact owned targets:"
|
||||||
|
printf ' container: %s\n results: %s\n model: %s\n model: %s\n' \
|
||||||
|
"$NAME" "$RESULT_DIR" "$QWOPUS" "$BARTOWSKI"
|
||||||
|
echo "Use --results, --models or --all to delete these exact targets."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--results|--models|--all) ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [--results|--models|--all]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if [[ $MODE == --results || $MODE == --all ]]; then
|
||||||
|
if [[ -d $RESULT_DIR ]]; then
|
||||||
|
find "$RESULT_DIR" -maxdepth 1 -type f -name '*.json' -delete
|
||||||
|
rmdir "$RESULT_DIR" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $MODE == --models || $MODE == --all ]]; then
|
||||||
|
rm -f -- "$QWOPUS" "$QWOPUS.part" "$BARTOWSKI" "$BARTOWSKI.part"
|
||||||
|
rmdir "$MODEL_ROOT/qwopus" "$MODEL_ROOT/bartowski" 2>/dev/null || true
|
||||||
|
rmdir "$MODEL_ROOT" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cleanup complete for mode $MODE. No production model path was addressed."
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=candidates.sh
|
||||||
|
source "$SCRIPT_DIR/candidates.sh"
|
||||||
|
candidate_config "${1:-}"
|
||||||
|
|
||||||
|
target_dir="$MODEL_ROOT/$CANDIDATE_ID"
|
||||||
|
target="$target_dir/$MODEL_FILE"
|
||||||
|
partial="$target.part"
|
||||||
|
|
||||||
|
install -d -m 0755 "$target_dir"
|
||||||
|
|
||||||
|
if [[ -s $target ]]; then
|
||||||
|
printf '%s %s\n' "$MODEL_SHA256" "$target" | sha256sum -c -
|
||||||
|
echo "Already prepared: $target"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Downloading pinned $CANDIDATE_ID artifact ($MODEL_SIZE bytes)"
|
||||||
|
curl --fail --location --retry 8 --retry-delay 5 --continue-at - \
|
||||||
|
--output "$partial" "$MODEL_URL"
|
||||||
|
|
||||||
|
actual_size=$(stat -c %s "$partial")
|
||||||
|
if [[ $actual_size != "$MODEL_SIZE" ]]; then
|
||||||
|
echo "Size mismatch: expected $MODEL_SIZE, got $actual_size; keeping $partial for inspection" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s %s\n' "$MODEL_SHA256" "$partial" | sha256sum -c -
|
||||||
|
mv -f "$partial" "$target"
|
||||||
|
echo "Prepared and verified: $target"
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
MODEL_ROOT=${MODEL_ROOT:-/data/models/experiments/qwen38-ab-20260907}
|
||||||
|
RESULT_DIR=${RESULT_DIR:-/data/benchmarks/qwen38-ab-20260907}
|
||||||
|
|
||||||
|
echo "== Containers owned by this experiment =="
|
||||||
|
docker ps -a --filter label=mike-ai.experiment=qwen38-ab-20260907 \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}\t{{.Label "mike-ai.candidate"}}' || true
|
||||||
|
|
||||||
|
echo "== Candidate model files and partial downloads =="
|
||||||
|
if [[ -d $MODEL_ROOT ]]; then
|
||||||
|
find "$MODEL_ROOT" -maxdepth 2 -type f -exec ls -lh {} +
|
||||||
|
else
|
||||||
|
echo "Not present: $MODEL_ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Benchmark results =="
|
||||||
|
if [[ -d $RESULT_DIR ]]; then
|
||||||
|
find "$RESULT_DIR" -maxdepth 1 -type f -exec ls -lh {} +
|
||||||
|
else
|
||||||
|
echo "Not present: $RESULT_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Known older experimental model directories (read-only report) =="
|
||||||
|
for path in \
|
||||||
|
/data/models/qwen3.8-27b-dirk \
|
||||||
|
/data/models/qwen3.8-27b-gsq-rco-test \
|
||||||
|
/data/models/qwen3.8-27b-iq4-mix \
|
||||||
|
/data/models/qwen3.8-27b-iq4-xs-pure; do
|
||||||
|
[[ -d $path ]] && du -sh "$path"
|
||||||
|
done
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
LABEL=${1:-}
|
||||||
|
CONTEXT=${2:-160000}
|
||||||
|
[[ -n $LABEL && $LABEL =~ ^[a-zA-Z0-9._-]+$ ]] || {
|
||||||
|
echo "Usage: $0 SAFE_LABEL [CONTEXT]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[[ $CONTEXT =~ ^[0-9]+$ ]] || {
|
||||||
|
echo "CONTEXT must be an integer" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REFERENCE_DIR="$SCRIPT_DIR/../dirk-qwen38"
|
||||||
|
RESULT_DIR=${RESULT_DIR:-/data/benchmarks/qwen38-ab-20260907}
|
||||||
|
TASKS=${TASKS:-/opt/mike-ai/stack/dev/QWEN38-FINAL-ACCEPTANCE-v1.json}
|
||||||
|
|
||||||
|
curl -fsS --max-time 3 http://127.0.0.1:5005/health >/dev/null
|
||||||
|
python3 "$REFERENCE_DIR/bench-case.py" "$LABEL" "$CONTEXT" \
|
||||||
|
--base http://127.0.0.1:5005 --output "$RESULT_DIR"
|
||||||
|
python3 "$REFERENCE_DIR/quality-ab.py" "$LABEL" \
|
||||||
|
--base http://127.0.0.1:5005 --tasks "$TASKS" --output "$RESULT_DIR"
|
||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
CANDIDATE=${1:-}
|
||||||
|
CONTEXT=${2:-}
|
||||||
|
SPLIT=${3:-}
|
||||||
|
|
||||||
|
if [[ -z $CANDIDATE || -z $CONTEXT || -z $SPLIT || ! $CONTEXT =~ ^[0-9]+$ || ! $SPLIT =~ ^[0-9]+,[0-9]+$ ]]; then
|
||||||
|
echo "Usage: $0 {qwopus|bartowski} CONTEXT TENSOR_SPLIT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=candidates.sh
|
||||||
|
source "$SCRIPT_DIR/candidates.sh"
|
||||||
|
candidate_config "$CANDIDATE"
|
||||||
|
|
||||||
|
HOST_MODEL_FILE=$MODEL_PATH
|
||||||
|
CONTAINER_MODEL="/models/$CANDIDATE_ID/$MODEL_FILE"
|
||||||
|
IMAGE=${LLAMA_IMAGE:-mike-ai/llama.cpp:local}
|
||||||
|
NAME=mike-ai-llama-qwen38-ab
|
||||||
|
RESULT_DIR=${RESULT_DIR:-/data/benchmarks/qwen38-ab-20260907}
|
||||||
|
|
||||||
|
[[ -s $HOST_MODEL_FILE ]] || {
|
||||||
|
echo "Candidate is not downloaded: $HOST_MODEL_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
printf '%s %s\n' "$MODEL_SHA256" "$HOST_MODEL_FILE" | sha256sum -c -
|
||||||
|
|
||||||
|
mapfile -t blockers < <(
|
||||||
|
docker ps --format '{{.Names}}' |
|
||||||
|
grep -E '^mike-ai-llama-' |
|
||||||
|
grep -vE '^mike-ai-llama-dashboard$|^mike-ai-llama-qwen38-ab$' || true
|
||||||
|
)
|
||||||
|
if ((${#blockers[@]})); then
|
||||||
|
printf 'Refusing to start: production model container(s) still running: %s\n' "${blockers[*]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker ps --format '{{.Names}}' | grep -qx "$NAME"; then
|
||||||
|
echo "Refusing to replace a running A/B container; run ./stop-case.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -m 0755 "$RESULT_DIR"
|
||||||
|
|
||||||
|
args=(
|
||||||
|
--model "$CONTAINER_MODEL"
|
||||||
|
--alias "$MODEL_ALIAS"
|
||||||
|
--ctx-size "$CONTEXT"
|
||||||
|
--flash-attn on
|
||||||
|
--cache-type-k q4_0
|
||||||
|
--cache-type-v q4_0
|
||||||
|
--cache-prompt
|
||||||
|
--cache-ram 8192
|
||||||
|
--threads 6
|
||||||
|
--threads-batch 6
|
||||||
|
--batch-size 64
|
||||||
|
--ubatch-size 32
|
||||||
|
--parallel 1
|
||||||
|
--jinja
|
||||||
|
--reasoning auto
|
||||||
|
--reasoning-budget 8192
|
||||||
|
--reasoning-preserve
|
||||||
|
--host 127.0.0.1
|
||||||
|
--port 5005
|
||||||
|
--metrics
|
||||||
|
--fit off
|
||||||
|
--n-gpu-layers all
|
||||||
|
--no-mmap
|
||||||
|
--temperature 1.0
|
||||||
|
--top-p 0.95
|
||||||
|
--top-k 20
|
||||||
|
--device CUDA0,CUDA1
|
||||||
|
--main-gpu 0
|
||||||
|
--split-mode layer
|
||||||
|
--tensor-split "$SPLIT"
|
||||||
|
--spec-type draft-mtp
|
||||||
|
--spec-draft-n-max 3
|
||||||
|
--spec-draft-type-k f16
|
||||||
|
--spec-draft-type-v f16
|
||||||
|
)
|
||||||
|
|
||||||
|
label="$CANDIDATE-ctx${CONTEXT}-split${SPLIT//,/-}"
|
||||||
|
docker run -d \
|
||||||
|
--name "$NAME" \
|
||||||
|
--gpus all \
|
||||||
|
--network host \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=256m \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--pids-limit 1024 \
|
||||||
|
--log-opt max-size=20m \
|
||||||
|
--log-opt max-file=2 \
|
||||||
|
-v "$MODEL_ROOT":/models:ro \
|
||||||
|
-v "$RESULT_DIR":/results \
|
||||||
|
--label mike-ai.experiment=qwen38-ab-20260907 \
|
||||||
|
--label mike-ai.candidate="$CANDIDATE" \
|
||||||
|
--label mike-ai.case="$label" \
|
||||||
|
"$IMAGE" "${args[@]}"
|
||||||
|
|
||||||
|
printf 'Started isolated case %s on http://127.0.0.1:5005\n' "$label"
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
docker rm -f mike-ai-llama-qwen38-ab >/dev/null 2>&1 || true
|
||||||
|
echo "A/B test container removed. Production was not changed."
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
NAME=mike-ai-llama-qwen38-ab
|
||||||
|
deadline=$((SECONDS + ${READY_TIMEOUT:-900}))
|
||||||
|
until curl -fsS --max-time 3 http://127.0.0.1:5005/health >/dev/null; do
|
||||||
|
state=$(docker inspect -f '{{.State.Running}}' "$NAME" 2>/dev/null || true)
|
||||||
|
if [[ $state != true ]]; then
|
||||||
|
docker logs --tail 100 "$NAME" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ((SECONDS >= deadline)); then
|
||||||
|
docker logs --tail 100 "$NAME" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
curl -fsS http://127.0.0.1:5005/props
|
||||||
|
printf '\nA/B test server is ready.\n'
|
||||||
+67
-22
@@ -37,11 +37,14 @@ if (( (8#$config_mode & 077) != 0 )); then
|
|||||||
fi
|
fi
|
||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
source "$CONFIG"
|
source "$CONFIG"
|
||||||
|
if [[ -n ${HF_TOKEN_FILE:-} && ! -r ${HF_TOKEN_FILE:-} && \
|
||||||
|
-r /etc/mike-ai/huggingface-token ]]; then
|
||||||
|
HF_TOKEN_FILE=/etc/mike-ai/huggingface-token
|
||||||
|
fi
|
||||||
|
|
||||||
required=(AI_HOSTNAME ADMIN_USER MODEL_DIR FAST_MODEL_FILE
|
required=(AI_HOSTNAME ADMIN_USER MODEL_DIR FAST_MODEL_FILE
|
||||||
FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL
|
FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL
|
||||||
MEDIUM_MODEL_SHA256 LARGE_MODEL_FILE LARGE_MODEL_URL LARGE_MODEL_SHA256
|
MEDIUM_MODEL_SHA256 LARGE_MODEL_FILE LARGE_MODEL_URL LARGE_MODEL_SHA256
|
||||||
BETA1_MODEL_FILE BETA1_MODEL_URL BETA1_MODEL_SHA256
|
|
||||||
ULTRA_MODEL_FILE ULTRA_MODEL_URL ULTRA_MODEL_SHA256
|
ULTRA_MODEL_FILE ULTRA_MODEL_URL ULTRA_MODEL_SHA256
|
||||||
UNCENSORED_MODEL_FILE UNCENSORED_MODEL_URL UNCENSORED_MODEL_SHA256
|
UNCENSORED_MODEL_FILE UNCENSORED_MODEL_URL UNCENSORED_MODEL_SHA256
|
||||||
UNCENSORED_PROJECTOR_FILE UNCENSORED_PROJECTOR_URL
|
UNCENSORED_PROJECTOR_FILE UNCENSORED_PROJECTOR_URL
|
||||||
@@ -70,7 +73,7 @@ install_base_packages() {
|
|||||||
apt-get update
|
apt-get update
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl git gnupg jq openssl wireguard-tools iptables \
|
ca-certificates curl git gnupg jq openssl wireguard-tools iptables \
|
||||||
iproute2 pciutils rsync unattended-upgrades ethtool age
|
iproute2 pciutils rsync unattended-upgrades ethtool age restic zstd
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_stable_network_name() {
|
setup_stable_network_name() {
|
||||||
@@ -304,6 +307,19 @@ install_stack_files() {
|
|||||||
printf '%s\n' "$source_head" >"$STACK_DIR/.mike-ai-source-commit"
|
printf '%s\n' "$source_head" >"$STACK_DIR/.mike-ai-source-commit"
|
||||||
fi
|
fi
|
||||||
install -d -m 0700 "$SECRETS_DIR"
|
install -d -m 0700 "$SECRETS_DIR"
|
||||||
|
# Keep the exact host bootstrap inputs with the protected system
|
||||||
|
# configuration. This breaks the former recovery cycle in which a fresh
|
||||||
|
# host needed a lost /root-only install file before it could restore backup.
|
||||||
|
if [[ $(realpath "$CONFIG") != $(realpath -m "$SECRETS_DIR/install.env") ]]; then
|
||||||
|
install -m 0600 "$CONFIG" "$SECRETS_DIR/install.env"
|
||||||
|
else
|
||||||
|
chmod 0600 "$SECRETS_DIR/install.env"
|
||||||
|
fi
|
||||||
|
if [[ -n ${HF_TOKEN_FILE:-} && -r $HF_TOKEN_FILE && \
|
||||||
|
$(realpath "$HF_TOKEN_FILE") != $(realpath -m "$SECRETS_DIR/huggingface-token") ]]; then
|
||||||
|
install -m 0600 "$HF_TOKEN_FILE" "$SECRETS_DIR/huggingface-token"
|
||||||
|
HF_TOKEN_FILE=$SECRETS_DIR/huggingface-token
|
||||||
|
fi
|
||||||
[[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key
|
[[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key
|
||||||
[[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token
|
[[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token
|
||||||
chmod 0600 "$SECRETS_DIR"/*
|
chmod 0600 "$SECRETS_DIR"/*
|
||||||
@@ -314,8 +330,6 @@ MODEL_DIR=$MODEL_DIR
|
|||||||
WIREGUARD_CONFIG_FILE=${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf}
|
WIREGUARD_CONFIG_FILE=${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf}
|
||||||
ROUTER_API_KEY=$(<$SECRETS_DIR/router-api-key)
|
ROUTER_API_KEY=$(<$SECRETS_DIR/router-api-key)
|
||||||
CONTROLLER_TOKEN=$(<$SECRETS_DIR/controller-token)
|
CONTROLLER_TOKEN=$(<$SECRETS_DIR/controller-token)
|
||||||
PIPER_TTS_VERSION=${PIPER_TTS_VERSION:-1.6.0}
|
|
||||||
PIPER_VOICE=${PIPER_VOICE:-de_DE-thorsten-high}
|
|
||||||
QWEN3_TTS_IMAGE=${QWEN3_TTS_IMAGE:-ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98}
|
QWEN3_TTS_IMAGE=${QWEN3_TTS_IMAGE:-ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98}
|
||||||
QWEN3_TTS_CACHE_DIR=$QWEN3_TTS_CACHE_DIR
|
QWEN3_TTS_CACHE_DIR=$QWEN3_TTS_CACHE_DIR
|
||||||
QWEN3_TTS_VOICES_DIR=$QWEN3_TTS_VOICES_DIR
|
QWEN3_TTS_VOICES_DIR=$QWEN3_TTS_VOICES_DIR
|
||||||
@@ -323,7 +337,6 @@ QWEN3_TTS_GPU_DEVICE=${QWEN3_TTS_GPU_DEVICE:-GPU-4834d9d7-5b61-3004-1fb3-4ae49d4
|
|||||||
AI_DNS=${WG_DNS:-1.1.1.1}
|
AI_DNS=${WG_DNS:-1.1.1.1}
|
||||||
FAST_MODEL_FILE=$FAST_MODEL_FILE
|
FAST_MODEL_FILE=$FAST_MODEL_FILE
|
||||||
MEDIUM_MODEL_FILE=$MEDIUM_MODEL_FILE
|
MEDIUM_MODEL_FILE=$MEDIUM_MODEL_FILE
|
||||||
BETA1_MODEL_FILE=$BETA1_MODEL_FILE
|
|
||||||
LARGE_MODEL_FILE=$LARGE_MODEL_FILE
|
LARGE_MODEL_FILE=$LARGE_MODEL_FILE
|
||||||
ULTRA_MODEL_FILE=$ULTRA_MODEL_FILE
|
ULTRA_MODEL_FILE=$ULTRA_MODEL_FILE
|
||||||
UNCENSORED_MODEL_FILE=$UNCENSORED_MODEL_FILE
|
UNCENSORED_MODEL_FILE=$UNCENSORED_MODEL_FILE
|
||||||
@@ -337,10 +350,6 @@ MEDIUM_CONTEXT=${MEDIUM_CONTEXT:-160000}
|
|||||||
MEDIUM_BATCH_SIZE=${MEDIUM_BATCH_SIZE:-2048}
|
MEDIUM_BATCH_SIZE=${MEDIUM_BATCH_SIZE:-2048}
|
||||||
MEDIUM_UBATCH_SIZE=${MEDIUM_UBATCH_SIZE:-128}
|
MEDIUM_UBATCH_SIZE=${MEDIUM_UBATCH_SIZE:-128}
|
||||||
MEDIUM_PARALLEL_SLOTS=${MEDIUM_PARALLEL_SLOTS:-1}
|
MEDIUM_PARALLEL_SLOTS=${MEDIUM_PARALLEL_SLOTS:-1}
|
||||||
BETA1_CONTEXT=${BETA1_CONTEXT:-192000}
|
|
||||||
BETA1_BATCH_SIZE=${BETA1_BATCH_SIZE:-2048}
|
|
||||||
BETA1_UBATCH_SIZE=${BETA1_UBATCH_SIZE:-128}
|
|
||||||
BETA1_PARALLEL_SLOTS=${BETA1_PARALLEL_SLOTS:-1}
|
|
||||||
LARGE_CONTEXT=${LARGE_CONTEXT:-192000}
|
LARGE_CONTEXT=${LARGE_CONTEXT:-192000}
|
||||||
LARGE_BATCH_SIZE=${LARGE_BATCH_SIZE:-2048}
|
LARGE_BATCH_SIZE=${LARGE_BATCH_SIZE:-2048}
|
||||||
LARGE_UBATCH_SIZE=${LARGE_UBATCH_SIZE:-128}
|
LARGE_UBATCH_SIZE=${LARGE_UBATCH_SIZE:-128}
|
||||||
@@ -356,7 +365,6 @@ UNCENSORED_PARALLEL_SLOTS=${UNCENSORED_PARALLEL_SLOTS:-1}
|
|||||||
FAST_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
FAST_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
||||||
MEDIUM_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
MEDIUM_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
||||||
MEDIUM_TENSOR_SPLIT=${MEDIUM_TENSOR_SPLIT:-90,10}
|
MEDIUM_TENSOR_SPLIT=${MEDIUM_TENSOR_SPLIT:-90,10}
|
||||||
BETA1_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
|
||||||
LARGE_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
LARGE_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
||||||
LARGE_TENSOR_SPLIT=${LARGE_TENSOR_SPLIT:-86,14}
|
LARGE_TENSOR_SPLIT=${LARGE_TENSOR_SPLIT:-86,14}
|
||||||
ULTRA_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
ULTRA_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
||||||
@@ -365,7 +373,8 @@ UNCENSORED_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDAR
|
|||||||
UNCENSORED_TENSOR_SPLIT=${UNCENSORED_TENSOR_SPLIT:-90,10}
|
UNCENSORED_TENSOR_SPLIT=${UNCENSORED_TENSOR_SPLIT:-90,10}
|
||||||
UNCENSORED_MTP_MAX=${UNCENSORED_MTP_MAX:-2}
|
UNCENSORED_MTP_MAX=${UNCENSORED_MTP_MAX:-2}
|
||||||
IMAGE_GPU_DEVICES=${IMAGE_GPU_DEVICES:-${TEXT_GPU_DEVICES:-0}}
|
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=${LLAMA_THREADS:-6}
|
||||||
LLAMA_THREADS_BATCH=${LLAMA_THREADS_BATCH:-6}
|
LLAMA_THREADS_BATCH=${LLAMA_THREADS_BATCH:-6}
|
||||||
LLAMA_CACHE_RAM_MIB=${LLAMA_CACHE_RAM_MIB:-32768}
|
LLAMA_CACHE_RAM_MIB=${LLAMA_CACHE_RAM_MIB:-32768}
|
||||||
@@ -374,6 +383,29 @@ EOF
|
|||||||
chmod 0600 $SECRETS_DIR/stack.env
|
chmod 0600 $SECRETS_DIR/stack.env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_disaster_backup() {
|
||||||
|
log "Externes Disaster-Backup installieren"
|
||||||
|
install -m 0755 "$ROOT_DIR/platform/backup/athena-disaster-backup" \
|
||||||
|
/usr/local/sbin/athena-disaster-backup
|
||||||
|
install -m 0644 "$ROOT_DIR/platform/backup/athena-disaster-backup.service" \
|
||||||
|
/etc/systemd/system/athena-disaster-backup.service
|
||||||
|
install -m 0644 "$ROOT_DIR/platform/backup/athena-disaster-backup.timer" \
|
||||||
|
/etc/systemd/system/athena-disaster-backup.timer
|
||||||
|
install -m 0755 "$ROOT_DIR/platform/backup/athena-export-backup" \
|
||||||
|
/usr/local/sbin/athena-export-backup
|
||||||
|
install -m 0644 "$ROOT_DIR/platform/backup/athena-export-backup.service" \
|
||||||
|
/etc/systemd/system/athena-export-backup.service
|
||||||
|
install -m 0644 "$ROOT_DIR/platform/backup/athena-export-backup.timer" \
|
||||||
|
/etc/systemd/system/athena-export-backup.timer
|
||||||
|
if [[ ! -e $SECRETS_DIR/disaster-backup.env ]]; then
|
||||||
|
install -m 0600 "$ROOT_DIR/config/disaster-backup.env.example" \
|
||||||
|
"$SECRETS_DIR/disaster-backup.env.example"
|
||||||
|
fi
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now athena-disaster-backup.timer
|
||||||
|
systemctl enable --now athena-export-backup.timer
|
||||||
|
}
|
||||||
|
|
||||||
download_one() {
|
download_one() {
|
||||||
local relative=$1 url=$2 expected=$3 target="$MODEL_DIR/$1"
|
local relative=$1 url=$2 expected=$3 target="$MODEL_DIR/$1"
|
||||||
install -d -m 0755 "$(dirname "$target")"
|
install -d -m 0755 "$(dirname "$target")"
|
||||||
@@ -404,7 +436,6 @@ download_models() {
|
|||||||
done <<EOF
|
done <<EOF
|
||||||
$FAST_MODEL_FILE|$FAST_MODEL_URL|$FAST_MODEL_SHA256
|
$FAST_MODEL_FILE|$FAST_MODEL_URL|$FAST_MODEL_SHA256
|
||||||
$MEDIUM_MODEL_FILE|$MEDIUM_MODEL_URL|$MEDIUM_MODEL_SHA256
|
$MEDIUM_MODEL_FILE|$MEDIUM_MODEL_URL|$MEDIUM_MODEL_SHA256
|
||||||
$BETA1_MODEL_FILE|$BETA1_MODEL_URL|$BETA1_MODEL_SHA256
|
|
||||||
$LARGE_MODEL_FILE|$LARGE_MODEL_URL|$LARGE_MODEL_SHA256
|
$LARGE_MODEL_FILE|$LARGE_MODEL_URL|$LARGE_MODEL_SHA256
|
||||||
$ULTRA_MODEL_FILE|$ULTRA_MODEL_URL|$ULTRA_MODEL_SHA256
|
$ULTRA_MODEL_FILE|$ULTRA_MODEL_URL|$ULTRA_MODEL_SHA256
|
||||||
$UNCENSORED_MODEL_FILE|$UNCENSORED_MODEL_URL|$UNCENSORED_MODEL_SHA256
|
$UNCENSORED_MODEL_FILE|$UNCENSORED_MODEL_URL|$UNCENSORED_MODEL_SHA256
|
||||||
@@ -486,25 +517,38 @@ build_and_start() {
|
|||||||
docker build --progress=plain --build-arg LLAMA_CPP_COMMIT="$commit" \
|
docker build --progress=plain --build-arg LLAMA_CPP_COMMIT="$commit" \
|
||||||
-f platform/docker/llama-cpp/Dockerfile -t mike-ai/llama.cpp:local .
|
-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
|
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
|
local flux_components=${FLUX_COMPONENT_DIR:-/data/models/FLUX.2-klein-9B-components}
|
||||||
log "FLUX.2-klein-4B laden"
|
local flux_transformer=${FLUX_TRANSFORMER_DIR:-/data/models/FLUX.2-klein-9B-fp8}
|
||||||
install -d -m 0755 "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}"
|
local hf_token_file=${HF_TOKEN_FILE:-/root/.cache/huggingface/token}
|
||||||
docker run --rm --entrypoint python \
|
if [[ ! -s $flux_components/model_index.json || \
|
||||||
-v "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}:/download" \
|
! -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 \
|
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')"
|
"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/*'])"
|
||||||
chmod -R a-w "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}"
|
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
|
fi
|
||||||
# Creates the tools network and deploys the only host-bound MCP: Operator.
|
# Creates the tools network and deploys the only host-bound MCP: Operator.
|
||||||
# Portable MCPs and Hermes live on Unraid and are restored through Appdata.
|
# Portable MCPs and Hermes live on Unraid and are restored through Appdata.
|
||||||
"$STACK_DIR/platform/mcp/install-tools.sh"
|
"$STACK_DIR/platform/mcp/install-tools.sh"
|
||||||
docker compose --env-file "$SECRETS_DIR/stack.env" --profile inference create \
|
docker compose --env-file "$SECRETS_DIR/stack.env" --profile inference create \
|
||||||
llama-fast llama-medium llama-beta1 llama-large llama-ultra llama-uncensored
|
llama-fast llama-medium llama-large llama-ultra llama-uncensored
|
||||||
docker compose --env-file "$SECRETS_DIR/stack.env" --profile image create image-worker
|
docker compose --env-file "$SECRETS_DIR/stack.env" --profile image create image-worker
|
||||||
docker volume inspect portainer_data >/dev/null 2>&1 || docker volume create portainer_data >/dev/null
|
docker volume inspect portainer_data >/dev/null 2>&1 || docker volume create portainer_data >/dev/null
|
||||||
docker compose --env-file "$SECRETS_DIR/stack.env" stop llama-dashboard portainer
|
|
||||||
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --build \
|
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --build \
|
||||||
wireguard-gateway qwen3-tts piper tts-gateway profile-controller router llama-dashboard portainer backup
|
wireguard-gateway qwen3-tts tts-gateway profile-controller router llama-dashboard portainer backup
|
||||||
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
|
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
|
||||||
systemctl restart mike-ai-container-vpn-guard.service
|
systemctl restart mike-ai-container-vpn-guard.service
|
||||||
fi
|
fi
|
||||||
@@ -565,6 +609,7 @@ install_stack_files
|
|||||||
download_models
|
download_models
|
||||||
install_routing_guard
|
install_routing_guard
|
||||||
build_and_start
|
build_and_start
|
||||||
|
install_disaster_backup
|
||||||
|
|
||||||
log "Installation abgeschlossen"
|
log "Installation abgeschlossen"
|
||||||
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
|
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Athena speech stack:
|
|||||||
2. Athena Whisper transcribes it,
|
2. Athena Whisper transcribes it,
|
||||||
3. OpenClaw's normal agent-consult path answers with its configured model and
|
3. OpenClaw's normal agent-consult path answers with its configured model and
|
||||||
tools,
|
tools,
|
||||||
4. Athena XTTS/Piper returns PCM audio to the Talk client.
|
4. Athena Qwen3-TTS returns PCM audio to the Talk client.
|
||||||
|
|
||||||
Long replies are synthesized incrementally. The first short phrase starts
|
Long replies are synthesized incrementally. The first short phrase starts
|
||||||
playing as soon as it is ready while the next phrase is generated in parallel.
|
playing as soon as it is ready while the next phrase is generated in parallel.
|
||||||
|
|||||||
+1
-1
@@ -324,7 +324,7 @@ export default definePluginEntry({
|
|||||||
label: "Athena Local Talk",
|
label: "Athena Local Talk",
|
||||||
aliases: ["athena", "local-athena"],
|
aliases: ["athena", "local-athena"],
|
||||||
defaultModel: "athena-local",
|
defaultModel: "athena-local",
|
||||||
voices: ["alloy", "claribel"],
|
voices: ["alloy"],
|
||||||
autoSelectOrder: 1,
|
autoSelectOrder: 1,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
transports: ["gateway-relay"],
|
transports: ["gateway-relay"],
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ class AthenaTalkBridge {
|
|||||||
const response = await fetch(`${this.cfg.baseUrl}/audio/speech`, {
|
const response = await fetch(`${this.cfg.baseUrl}/audio/speech`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...this.authHeaders() },
|
headers: { "Content-Type": "application/json", ...this.authHeaders() },
|
||||||
// Let Athena select its currently active local backend (XTTS or Piper).
|
// Athena normalizes the request and serves it through Qwen3-TTS.
|
||||||
body: JSON.stringify({ voice: this.cfg.voice, input: text, response_format: "wav" }),
|
body: JSON.stringify({ voice: this.cfg.voice, input: text, response_format: "wav" }),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -330,7 +330,7 @@ export default definePluginEntry({
|
|||||||
label: "Athena Local Talk",
|
label: "Athena Local Talk",
|
||||||
aliases: ["athena", "local-athena"],
|
aliases: ["athena", "local-athena"],
|
||||||
defaultModel: "athena-local",
|
defaultModel: "athena-local",
|
||||||
voices: ["alloy", "claribel"],
|
voices: ["alloy"],
|
||||||
autoSelectOrder: 1,
|
autoSelectOrder: 1,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
transports: ["gateway-relay"],
|
transports: ["gateway-relay"],
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "athena-talk",
|
|
||||||
"name": "Athena Local Talk",
|
|
||||||
"description": "Local German voice conversations through Athena without a public speech provider.",
|
|
||||||
"version": "0.2.0",
|
|
||||||
"enabledByDefault": true,
|
|
||||||
"activation": {
|
|
||||||
"onStartup": true,
|
|
||||||
"capabilities": [
|
|
||||||
"provider"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"contracts": {
|
|
||||||
"realtimeVoiceProviders": [
|
|
||||||
"athena-talk"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "openclaw-plugin-athena-talk",
|
|
||||||
"version": "0.2.0",
|
|
||||||
"description": "Private realtime Talk bridge for Athena STT, OpenClaw agent consult, and Athena TTS.",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"peerDependencies": {
|
|
||||||
"openclaw": ">=2026.8.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"openclaw": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"openclaw": {
|
|
||||||
"extensions": [
|
|
||||||
"./dist/index.js"
|
|
||||||
],
|
|
||||||
"compat": {
|
|
||||||
"pluginApi": ">=2026.8.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -51,24 +51,10 @@ case "$command" in
|
|||||||
shift
|
shift
|
||||||
[[ $# -eq 0 ]] || { echo "core akzeptiert keine weiteren Services" >&2; exit 2; }
|
[[ $# -eq 0 ]] || { echo "core akzeptiert keine weiteren Services" >&2; exit 2; }
|
||||||
run "$ROOT_DIR/platform/mcp/install-tools.sh"
|
run "$ROOT_DIR/platform/mcp/install-tools.sh"
|
||||||
# Release the old gateway namespace (and its fixed network addresses)
|
|
||||||
# before replacing its owner. Otherwise Docker can strand the host with
|
|
||||||
# the old namespace still held by these two consumers.
|
|
||||||
run "${compose[@]}" stop llama-dashboard portainer
|
|
||||||
run "${compose[@]}" up -d --build \
|
run "${compose[@]}" up -d --build \
|
||||||
wireguard-gateway piper xtts tts-gateway profile-controller router llama-dashboard portainer backup
|
wireguard-gateway qwen3-tts tts-gateway profile-controller router llama-dashboard portainer backup
|
||||||
else
|
else
|
||||||
rebind_gateway=false
|
|
||||||
for service in "$@"; do
|
|
||||||
[[ $service == wireguard-gateway ]] && rebind_gateway=true
|
|
||||||
done
|
|
||||||
if [[ $rebind_gateway == true ]]; then
|
|
||||||
run "${compose[@]}" stop llama-dashboard portainer
|
|
||||||
fi
|
|
||||||
run "${compose[@]}" up -d --build --no-deps "$@"
|
run "${compose[@]}" up -d --build --no-deps "$@"
|
||||||
if [[ $rebind_gateway == true ]]; then
|
|
||||||
run "${compose[@]}" up -d --no-deps --force-recreate llama-dashboard portainer
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
purge-legacy)
|
purge-legacy)
|
||||||
|
|||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Encrypted off-host backup for data-disk and total-loss recovery.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
CONFIG=${DISASTER_BACKUP_CONFIG:-/etc/mike-ai/disaster-backup.env}
|
||||||
|
STATE=/var/lib/mike-ai-disaster-backup
|
||||||
|
|
||||||
|
log() { printf '\n==> %s\n' "$*"; }
|
||||||
|
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||||
|
[[ -r $CONFIG ]] || die "Konfiguration fehlt: $CONFIG"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CONFIG"
|
||||||
|
[[ ${DISASTER_BACKUP_ENABLED:-false} == true ]] || die \
|
||||||
|
"Externes Backup ist noch nicht freigeschaltet (DISASTER_BACKUP_ENABLED=true)."
|
||||||
|
[[ -n ${RESTIC_REPOSITORY:-} ]] || die "RESTIC_REPOSITORY fehlt."
|
||||||
|
if [[ -n ${RESTIC_REQUIRE_MOUNT:-} ]]; then
|
||||||
|
mountpoint -q "$RESTIC_REQUIRE_MOUNT" || die \
|
||||||
|
"Externes Backupziel ist nicht eingehängt: $RESTIC_REQUIRE_MOUNT"
|
||||||
|
fi
|
||||||
|
[[ -n ${RESTIC_PASSWORD_FILE:-} && -r $RESTIC_PASSWORD_FILE ]] || die \
|
||||||
|
"RESTIC_PASSWORD_FILE fehlt oder ist nicht lesbar."
|
||||||
|
command -v restic >/dev/null || die "restic ist nicht installiert."
|
||||||
|
command -v docker >/dev/null || die "Docker ist nicht installiert."
|
||||||
|
exec 9>/run/lock/athena-disaster-backup.lock
|
||||||
|
flock -n 9 || die "Ein Disaster-Backup läuft bereits."
|
||||||
|
|
||||||
|
install -d -m 0700 "$STATE/latest"
|
||||||
|
|
||||||
|
log "Konsistentes Docker-Schnellbackup erzeugen"
|
||||||
|
docker inspect mike-ai-backup >/dev/null 2>&1 || die "mike-ai-backup fehlt."
|
||||||
|
docker exec mike-ai-backup backup
|
||||||
|
latest=$(readlink -f /data/docker-backups/athena-latest.tar.gz)
|
||||||
|
[[ -s $latest ]] || die "Lokales Docker-Backup wurde nicht erzeugt."
|
||||||
|
gzip -t "$latest" || die "Lokales Docker-Backup ist beschädigt."
|
||||||
|
install -m 0600 "$latest" "$STATE/latest/docker-state.tar.gz"
|
||||||
|
sha256sum "$STATE/latest/docker-state.tar.gz" >"$STATE/latest/docker-state.tar.gz.sha256"
|
||||||
|
|
||||||
|
log "Wiederaufbau-Metadaten erfassen"
|
||||||
|
{
|
||||||
|
printf 'created_utc=%s\n' "$(date -u +%FT%TZ)"
|
||||||
|
printf 'hostname=%s\n' "$(hostname)"
|
||||||
|
printf 'source_commit=%s\n' "$(git -C /opt/mike-ai/stack rev-parse HEAD 2>/dev/null || printf unknown)"
|
||||||
|
findmnt -rn -o SOURCE,UUID,FSTYPE,TARGET / /data 2>/dev/null || true
|
||||||
|
} >"$STATE/latest/manifest.txt"
|
||||||
|
find /data/models -type f -printf '%P\t%s\n' 2>/dev/null | sort \
|
||||||
|
>"$STATE/latest/model-manifest.tsv"
|
||||||
|
docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' \
|
||||||
|
>"$STATE/latest/container-manifest.tsv"
|
||||||
|
|
||||||
|
paths=(/etc/mike-ai /opt/mike-ai "$STATE/latest")
|
||||||
|
for path in \
|
||||||
|
/data/voice /data/music /data/audio /data/llama-dashboard \
|
||||||
|
/data/mike-ai-operator /data/benchmarks /data/model-benchmarks \
|
||||||
|
/data/image-comparison /data/backups /data/deploy-backups; do
|
||||||
|
[[ ! -e $path ]] || paths+=("$path")
|
||||||
|
done
|
||||||
|
|
||||||
|
tag=${RESTIC_TAG:-athena-disaster}
|
||||||
|
log "Verschlüsseltes externes Backup schreiben"
|
||||||
|
if ! restic snapshots >/dev/null 2>&1; then
|
||||||
|
log "Neues Restic-Repository initialisieren"
|
||||||
|
restic init
|
||||||
|
fi
|
||||||
|
restic backup --tag "$tag" "${paths[@]}"
|
||||||
|
|
||||||
|
log "Aufbewahrung anwenden"
|
||||||
|
restic forget --tag "$tag" \
|
||||||
|
--keep-daily "${RESTIC_KEEP_DAILY:-14}" \
|
||||||
|
--keep-weekly "${RESTIC_KEEP_WEEKLY:-8}" \
|
||||||
|
--keep-monthly "${RESTIC_KEEP_MONTHLY:-12}" --prune
|
||||||
|
|
||||||
|
log "Letzten Snapshot verifizieren"
|
||||||
|
restic snapshots --tag "$tag" --latest 1
|
||||||
|
restic check
|
||||||
|
printf 'ATHENA_DISASTER_BACKUP_OK\n'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Encrypted off-host disaster backup for Athena
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
ConditionPathExists=/etc/mike-ai/disaster-backup.env
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/athena-disaster-backup
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=best-effort
|
||||||
|
IOSchedulingPriority=7
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nightly Athena off-host disaster backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:15:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=30m
|
||||||
|
Unit=athena-disaster-backup.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a browser-downloadable, encrypted archive of irreplaceable Athena data.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
OUTPUT_DIR=${ATHENA_EXPORT_DIR:-/data/emergency-backups}
|
||||||
|
RECIPIENT_FILE=${ATHENA_AGE_RECIPIENT_FILE:-/etc/mike-ai/recovery.age-recipient}
|
||||||
|
STATE=/var/lib/mike-ai-disaster-backup/latest
|
||||||
|
KEEP=${ATHENA_EXPORT_KEEP:-5}
|
||||||
|
|
||||||
|
log() { printf '\n==> %s\n' "$*"; }
|
||||||
|
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||||
|
[[ -s $RECIPIENT_FILE ]] || die "Age-Empfänger fehlt: $RECIPIENT_FILE"
|
||||||
|
[[ $KEEP =~ ^[1-9][0-9]*$ ]] || die "ATHENA_EXPORT_KEEP muss positiv sein."
|
||||||
|
for command in age zstd tar docker sha256sum flock; do
|
||||||
|
command -v "$command" >/dev/null || die "$command fehlt."
|
||||||
|
done
|
||||||
|
exec 9>/run/lock/athena-export-backup.lock
|
||||||
|
flock -n 9 || die "Ein exportierbares Backup läuft bereits."
|
||||||
|
|
||||||
|
install -d -m 0755 "$OUTPUT_DIR"
|
||||||
|
install -d -m 0700 "$STATE"
|
||||||
|
|
||||||
|
log "Aktuellen Docker-Zustand sichern"
|
||||||
|
docker exec mike-ai-backup backup
|
||||||
|
latest=$(readlink -f /data/docker-backups/athena-latest.tar.gz)
|
||||||
|
[[ -s $latest ]] || die "Docker-Zustandsbackup fehlt."
|
||||||
|
gzip -t "$latest" || die "Docker-Zustandsbackup ist beschädigt."
|
||||||
|
install -m 0600 "$latest" "$STATE/docker-state.tar.gz"
|
||||||
|
|
||||||
|
stamp=$(date -u +%Y-%m-%dT%H-%M-%SZ)
|
||||||
|
name="athena-portable-$stamp.tar.zst.age"
|
||||||
|
partial="$OUTPUT_DIR/.$name.partial"
|
||||||
|
target="$OUTPUT_DIR/$name"
|
||||||
|
list=$(mktemp /tmp/athena-export-list.XXXXXX)
|
||||||
|
trap 'rm -f "$list" "$partial"' EXIT
|
||||||
|
|
||||||
|
add_path() {
|
||||||
|
local path=${1#/}
|
||||||
|
[[ ! -e /$path ]] || printf '%s\0' "$path" >>"$list"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reproducible model/HF caches are deliberately omitted. Everything below is
|
||||||
|
# either a host configuration, project source, user input or generated result.
|
||||||
|
add_path /etc/mike-ai
|
||||||
|
add_path /opt/mike-ai
|
||||||
|
add_path /var/lib/mike-ai-disaster-backup/latest
|
||||||
|
add_path /data/voice/applio/logs
|
||||||
|
add_path /data/voice/applio/datasets
|
||||||
|
add_path /data/voice/applio/config.json
|
||||||
|
add_path /data/voice/omnivoice/output
|
||||||
|
add_path /data/voice/xvc/output
|
||||||
|
add_path /data/voice/studio
|
||||||
|
add_path /data/music
|
||||||
|
add_path /data/audio
|
||||||
|
add_path /data/llama-dashboard
|
||||||
|
add_path /data/mike-ai-operator
|
||||||
|
add_path /data/benchmarks
|
||||||
|
add_path /data/model-benchmarks
|
||||||
|
add_path /data/image-comparison
|
||||||
|
add_path /data/backups
|
||||||
|
add_path /data/deploy-backups
|
||||||
|
|
||||||
|
log "Portables, verschlüsseltes Backup erzeugen"
|
||||||
|
tar --create --numeric-owner --acls --xattrs -C / --null --files-from="$list" \
|
||||||
|
| zstd -T0 -3 \
|
||||||
|
| age -R "$RECIPIENT_FILE" -o "$partial"
|
||||||
|
chmod 0644 "$partial"
|
||||||
|
mv "$partial" "$target"
|
||||||
|
sha256sum "$target" >"$target.sha256"
|
||||||
|
chmod 0644 "$target.sha256"
|
||||||
|
|
||||||
|
log "Nur die letzten $KEEP Generationen behalten"
|
||||||
|
mapfile -t old < <(find "$OUTPUT_DIR" -maxdepth 1 -type f \
|
||||||
|
-name 'athena-portable-*.tar.zst.age' -printf '%T@ %p\n' | sort -rn | tail -n +$((KEEP + 1)) | cut -d' ' -f2-)
|
||||||
|
for archive in "${old[@]}"; do
|
||||||
|
rm -f -- "$archive" "$archive.sha256"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'ATHENA_EXPORT_BACKUP_OK file=%s bytes=%s\n' "$target" "$(stat -c %s "$target")"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Create encrypted downloadable Athena recovery package
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
ConditionPathExists=/etc/mike-ai/recovery.age-recipient
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/athena-export-backup
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=best-effort
|
||||||
|
IOSchedulingPriority=7
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Create an Athena recovery package every five hours
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=45m
|
||||||
|
OnUnitActiveSec=5h
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=10m
|
||||||
|
Unit=athena-export-backup.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -13,9 +13,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends python3.12-venv
|
|||||||
"transformers==${TRANSFORMERS_VERSION}" \
|
"transformers==${TRANSFORMERS_VERSION}" \
|
||||||
"accelerate==${ACCELERATE_VERSION}" \
|
"accelerate==${ACCELERATE_VERSION}" \
|
||||||
"huggingface-hub==${HF_HUB_VERSION}" \
|
"huggingface-hub==${HF_HUB_VERSION}" \
|
||||||
|
"nvidia-modelopt==0.46.0" bitsandbytes \
|
||||||
sentencepiece protobuf safetensors pillow && \
|
sentencepiece protobuf safetensors pillow && \
|
||||||
useradd --system --uid 10002 --home /nonexistent --shell /usr/sbin/nologin image-worker
|
useradd --system --uid 10002 --home /nonexistent --shell /usr/sbin/nologin image-worker
|
||||||
|
|
||||||
COPY image_worker.py /app/image_worker.py
|
COPY image_worker.py /app/image_worker.py
|
||||||
|
COPY image_worker_9b.py /app/image_worker_9b.py
|
||||||
USER 10002:10002
|
USER 10002:10002
|
||||||
ENTRYPOINT ["/opt/image-venv/bin/python", "/app/image_worker.py"]
|
ENTRYPOINT ["/opt/image-venv/bin/python", "/app/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()
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
FROM python:3.12-slim-bookworm
|
|
||||||
|
|
||||||
ARG PIPER_TTS_VERSION=1.6.0
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl ffmpeg gosu \
|
|
||||||
&& python -m pip install --no-cache-dir "piper-tts==${PIPER_TTS_VERSION}" \
|
|
||||||
&& useradd --system --uid 10003 --home-dir /nonexistent --shell /usr/sbin/nologin piper \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY piper_worker.py /app/piper_worker.py
|
|
||||||
COPY entrypoint.sh /usr/local/bin/mike-ai-piper-entrypoint
|
|
||||||
RUN chmod 0755 /usr/local/bin/mike-ai-piper-entrypoint
|
|
||||||
|
|
||||||
ENV PIPER_DATA_DIR=/data \
|
|
||||||
PIPER_VOICE=de_DE-thorsten-high \
|
|
||||||
PIPER_VOICE_ALIAS=alloy \
|
|
||||||
PIPER_HOST=0.0.0.0 \
|
|
||||||
PIPER_PORT=8085
|
|
||||||
|
|
||||||
VOLUME ["/data"]
|
|
||||||
EXPOSE 8085
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mike-ai-piper-entrypoint"]
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
data_dir=${PIPER_DATA_DIR:-/data}
|
|
||||||
voice=${PIPER_VOICE:-de_DE-thorsten-high}
|
|
||||||
|
|
||||||
mkdir -p "$data_dir"
|
|
||||||
chown 10003:10003 "$data_dir"
|
|
||||||
|
|
||||||
if [ ! -s "$data_dir/$voice.onnx" ] || [ ! -s "$data_dir/$voice.onnx.json" ]; then
|
|
||||||
echo "Downloading Piper voice: $voice"
|
|
||||||
gosu piper python -m piper.download_voices --data-dir "$data_dir" "$voice"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec gosu piper python /app/piper_worker.py
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Small, private Piper worker for the Mike AI profile router.
|
|
||||||
|
|
||||||
The public OpenAI-compatible endpoint remains in the router. This worker only
|
|
||||||
accepts the narrow internal /status and /tts protocol and never logs input text.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import wave
|
|
||||||
from http import HTTPStatus
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from piper import PiperVoice, SynthesisConfig
|
|
||||||
|
|
||||||
|
|
||||||
DATA_DIR = Path(os.getenv("PIPER_DATA_DIR", "/data"))
|
|
||||||
VOICE_NAME = os.getenv("PIPER_VOICE", "de_DE-thorsten-high")
|
|
||||||
VOICE_ALIAS = os.getenv("PIPER_VOICE_ALIAS", "alloy")
|
|
||||||
HOST = os.getenv("PIPER_HOST", "0.0.0.0")
|
|
||||||
PORT = int(os.getenv("PIPER_PORT", "8085"))
|
|
||||||
MAX_TEXT_CHARS = int(os.getenv("PIPER_MAX_TEXT_CHARS", "8000"))
|
|
||||||
MAX_REQUEST_BYTES = int(os.getenv("PIPER_MAX_REQUEST_BYTES", "65536"))
|
|
||||||
|
|
||||||
VOICE_PATH = DATA_DIR / f"{VOICE_NAME}.onnx"
|
|
||||||
VOICE = PiperVoice.load(str(VOICE_PATH))
|
|
||||||
SYNTHESIS_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize_wav(text: str, speed: float) -> bytes:
|
|
||||||
"""Synthesize a complete WAV in memory without retaining the text."""
|
|
||||||
output = io.BytesIO()
|
|
||||||
config = SynthesisConfig(length_scale=1.0 / speed)
|
|
||||||
with SYNTHESIS_LOCK, wave.open(output, "wb") as wav_file:
|
|
||||||
VOICE.synthesize_wav(text, wav_file, syn_config=config)
|
|
||||||
return output.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def wav_to_mp3(wav_bytes: bytes) -> bytes:
|
|
||||||
"""Convert Piper's WAV to the MP3 format Open WebUI requests by default."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
|
||||||
"-f", "wav", "-i", "pipe:0",
|
|
||||||
"-codec:a", "libmp3lame", "-b:a", "96k",
|
|
||||||
"-f", "mp3", "pipe:1",
|
|
||||||
],
|
|
||||||
input=wav_bytes,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
check=False,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError("ffmpeg conversion failed")
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
protocol_version = "HTTP/1.1"
|
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: object) -> None:
|
|
||||||
# Deliberately omit URLs and request bodies from the log.
|
|
||||||
print(f"piper-worker: {self.command} -> {args[1] if len(args) > 1 else '-'}")
|
|
||||||
|
|
||||||
def send_bytes(self, status: int, body: bytes, content_type: str) -> None:
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.send_header("Cache-Control", "no-store")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def send_json(self, status: int, payload: dict) -> None:
|
|
||||||
self.send_bytes(
|
|
||||||
status,
|
|
||||||
json.dumps(payload, separators=(",", ":")).encode(),
|
|
||||||
"application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
|
||||||
if self.path != "/status":
|
|
||||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
|
||||||
return
|
|
||||||
self.send_json(
|
|
||||||
HTTPStatus.OK,
|
|
||||||
{
|
|
||||||
"ready": True,
|
|
||||||
"engine": "piper",
|
|
||||||
"model": VOICE_NAME,
|
|
||||||
"voices": [VOICE_ALIAS],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802
|
|
||||||
if self.path != "/tts":
|
|
||||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
content_length = int(self.headers.get("Content-Length", "0"))
|
|
||||||
except ValueError:
|
|
||||||
content_length = 0
|
|
||||||
if content_length <= 0 or content_length > MAX_REQUEST_BYTES:
|
|
||||||
self.send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "invalid request size"})
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
request = json.loads(self.rfile.read(content_length))
|
|
||||||
text = request.get("text", "")
|
|
||||||
voice = request.get("voice", VOICE_ALIAS)
|
|
||||||
output_format = request.get("format", "mp3")
|
|
||||||
speed = float(request.get("speed", 1.0))
|
|
||||||
except (json.JSONDecodeError, TypeError, ValueError):
|
|
||||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid JSON request"})
|
|
||||||
return
|
|
||||||
|
|
||||||
if not isinstance(text, str) or not text.strip() or len(text) > MAX_TEXT_CHARS:
|
|
||||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid text"})
|
|
||||||
return
|
|
||||||
if voice != VOICE_ALIAS:
|
|
||||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "unknown voice"})
|
|
||||||
return
|
|
||||||
if output_format not in {"wav", "mp3"}:
|
|
||||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "unsupported format"})
|
|
||||||
return
|
|
||||||
if not 0.5 <= speed <= 2.0:
|
|
||||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid speed"})
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
audio = synthesize_wav(text.strip(), speed)
|
|
||||||
if output_format == "mp3":
|
|
||||||
audio = wav_to_mp3(audio)
|
|
||||||
content_type = "audio/mpeg"
|
|
||||||
else:
|
|
||||||
content_type = "audio/wav"
|
|
||||||
except (OSError, RuntimeError, subprocess.SubprocessError):
|
|
||||||
self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "synthesis failed"})
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_bytes(HTTPStatus.OK, audio, content_type)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print(f"Piper worker ready: {VOICE_NAME} as {VOICE_ALIAS} on {HOST}:{PORT}")
|
|
||||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
|
||||||
@@ -132,6 +132,25 @@ class LanguageSegmentationTests(unittest.TestCase):
|
|||||||
self.assertIn("5 bis 6 Uhr", spoken)
|
self.assertIn("5 bis 6 Uhr", spoken)
|
||||||
self.assertIn("Wind maximal 16 Kilometer pro Stunde", spoken)
|
self.assertIn("Wind maximal 16 Kilometer pro Stunde", spoken)
|
||||||
|
|
||||||
|
def test_qwen_speaks_aspect_ratios_as_ratios(self):
|
||||||
|
spoken = gateway.prepare_for_qwen_speech(
|
||||||
|
"Cover sind im Hochformat (2:3), Screenshots im Querformat "
|
||||||
|
"(16:9), ein Quadrat im Seitenverhältnis 2:2 und 4:3-Format."
|
||||||
|
)
|
||||||
|
self.assertIn("Hochformat (2 zu 3)", spoken)
|
||||||
|
self.assertIn("Querformat (16 zu 9)", spoken)
|
||||||
|
self.assertIn("Seitenverhältnis 2 zu 2", spoken)
|
||||||
|
self.assertIn("4 zu 3-Format", spoken)
|
||||||
|
|
||||||
|
def test_qwen_keeps_clock_times_distinct_from_aspect_ratios(self):
|
||||||
|
spoken = gateway.prepare_for_qwen_speech(
|
||||||
|
"Beginn um 16:09 Uhr, Fehler um 02:14; das Videoformat ist 16:9."
|
||||||
|
)
|
||||||
|
self.assertIn("16 Uhr 9", spoken)
|
||||||
|
self.assertNotIn("16 Uhr 9 Uhr", spoken)
|
||||||
|
self.assertIn("2 Uhr 14", spoken)
|
||||||
|
self.assertIn("Videoformat ist 16 zu 9", spoken)
|
||||||
|
|
||||||
def test_qwen_speaks_strict_date_ranges_as_calendar_dates(self):
|
def test_qwen_speaks_strict_date_ranges_as_calendar_dates(self):
|
||||||
spoken = gateway.prepare_for_qwen_speech(
|
spoken = gateway.prepare_for_qwen_speech(
|
||||||
"Neuigkeiten vom 04.–05.09. und Vergleich 04.09.–06.10.2026."
|
"Neuigkeiten vom 04.–05.09. und Vergleich 04.09.–06.10.2026."
|
||||||
@@ -225,25 +244,20 @@ class LanguageSegmentationTests(unittest.TestCase):
|
|||||||
self.assertTrue(all(len(part.split()) > 4 for _, part in segments))
|
self.assertTrue(all(len(part.split()) > 4 for _, part in segments))
|
||||||
|
|
||||||
|
|
||||||
class FallbackTests(unittest.TestCase):
|
class BackendFailureTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.original_xtts = gateway.synthesize_xtts
|
self.original_qwen = gateway.synthesize_qwen
|
||||||
self.original_piper = gateway.synthesize_piper
|
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
gateway.synthesize_xtts = self.original_xtts
|
gateway.synthesize_qwen = self.original_qwen
|
||||||
gateway.synthesize_piper = self.original_piper
|
|
||||||
|
|
||||||
def test_piper_is_used_when_xtts_fails(self):
|
def test_qwen_failure_is_reported_without_fallback(self):
|
||||||
def fail(*_args):
|
def fail(*_args):
|
||||||
raise RuntimeError("synthetic XTTS failure")
|
raise RuntimeError("synthetic Qwen failure")
|
||||||
|
|
||||||
gateway.synthesize_xtts = fail
|
gateway.synthesize_qwen = fail
|
||||||
gateway.synthesize_piper = lambda *_args: (b"piper", "audio/wav")
|
with self.assertRaisesRegex(RuntimeError, "synthetic Qwen failure"):
|
||||||
self.assertEqual(
|
gateway.synthesize("synthetic test", "wav", 1.0)
|
||||||
gateway.synthesize("synthetic test", "wav", 1.0),
|
|
||||||
(b"piper", "audio/wav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AudioJoinTests(unittest.TestCase):
|
class AudioJoinTests(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Private Qwen3-TTS-first gateway with a Piper fallback.
|
"""Private Qwen3-TTS gateway.
|
||||||
|
|
||||||
The gateway implements the narrow /status and /tts protocol already consumed
|
The gateway implements the narrow /status and /tts protocol already consumed
|
||||||
by the profile router. Request text is never logged or persisted.
|
by the profile router. Request text is never logged or persisted.
|
||||||
@@ -8,6 +8,7 @@ by the profile router. Request text is never logged or persisted.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -17,6 +18,7 @@ import time
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
import wave
|
import wave
|
||||||
from array import array
|
from array import array
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -31,7 +33,6 @@ QWEN_TTS_VOICE = os.getenv("QWEN_TTS_VOICE", "serena")
|
|||||||
QWEN_TTS_LANGUAGE = os.getenv("QWEN_TTS_LANGUAGE", "German")
|
QWEN_TTS_LANGUAGE = os.getenv("QWEN_TTS_LANGUAGE", "German")
|
||||||
QWEN_TTS_TIMEOUT = float(os.getenv("QWEN_TTS_TIMEOUT", "120"))
|
QWEN_TTS_TIMEOUT = float(os.getenv("QWEN_TTS_TIMEOUT", "120"))
|
||||||
XTTS_URL = os.getenv("XTTS_URL", "http://xtts:80").rstrip("/")
|
XTTS_URL = os.getenv("XTTS_URL", "http://xtts:80").rstrip("/")
|
||||||
PIPER_URL = os.getenv("PIPER_URL", "http://piper:8085").rstrip("/")
|
|
||||||
VOICE_ALIAS = os.getenv("TTS_VOICE_ALIAS", "alloy")
|
VOICE_ALIAS = os.getenv("TTS_VOICE_ALIAS", "alloy")
|
||||||
XTTS_SPEAKER = os.getenv("XTTS_SPEAKER", "Annmarie Nele")
|
XTTS_SPEAKER = os.getenv("XTTS_SPEAKER", "Annmarie Nele")
|
||||||
DEFAULT_LANGUAGE = os.getenv("TTS_DEFAULT_LANGUAGE", "de")
|
DEFAULT_LANGUAGE = os.getenv("TTS_DEFAULT_LANGUAGE", "de")
|
||||||
@@ -41,7 +42,6 @@ MAX_TEXT_CHARS = int(os.getenv("TTS_MAX_TEXT_CHARS", "8000"))
|
|||||||
MAX_REQUEST_BYTES = int(os.getenv("TTS_MAX_REQUEST_BYTES", "65536"))
|
MAX_REQUEST_BYTES = int(os.getenv("TTS_MAX_REQUEST_BYTES", "65536"))
|
||||||
MAX_AUDIO_BYTES = int(os.getenv("TTS_MAX_AUDIO_BYTES", str(64 * 1024 * 1024)))
|
MAX_AUDIO_BYTES = int(os.getenv("TTS_MAX_AUDIO_BYTES", str(64 * 1024 * 1024)))
|
||||||
XTTS_TIMEOUT = float(os.getenv("XTTS_TIMEOUT", "120"))
|
XTTS_TIMEOUT = float(os.getenv("XTTS_TIMEOUT", "120"))
|
||||||
PIPER_TIMEOUT = float(os.getenv("PIPER_TIMEOUT", "120"))
|
|
||||||
QUEUE_TIMEOUT = float(os.getenv("XTTS_QUEUE_TIMEOUT", "15"))
|
QUEUE_TIMEOUT = float(os.getenv("XTTS_QUEUE_TIMEOUT", "15"))
|
||||||
# XTTS loses natural prosody when a sentence is synthesized as many tiny
|
# XTTS loses natural prosody when a sentence is synthesized as many tiny
|
||||||
# requests: every request starts a fresh utterance. Keep complete sentences
|
# requests: every request starts a fresh utterance. Keep complete sentences
|
||||||
@@ -61,8 +61,7 @@ SPEAKER_LOCK = threading.Lock()
|
|||||||
SPEAKER_CONDITIONING: dict | None = None
|
SPEAKER_CONDITIONING: dict | None = None
|
||||||
STATE = {
|
STATE = {
|
||||||
"last_backend": None,
|
"last_backend": None,
|
||||||
"xtts_failures": 0,
|
"qwen_failures": 0,
|
||||||
"piper_fallbacks": 0,
|
|
||||||
"last_error": None,
|
"last_error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +266,36 @@ def _spoken_ipv4(match: re.Match) -> str:
|
|||||||
return " Punkt ".join(str(int(part)) for part in match.group(0).split("."))
|
return " Punkt ".join(str(int(part)) for part in match.group(0).split("."))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_aspect_ratios(text: str) -> str:
|
||||||
|
"""Speak colon notation as a ratio only when the surrounding text says so.
|
||||||
|
|
||||||
|
A bare ``16:09`` remains a clock time. This deliberately avoids a global
|
||||||
|
replacement of common ratios because ``16:9`` can also be a valid time.
|
||||||
|
"""
|
||||||
|
cue = (
|
||||||
|
r"(?:Seitenverh[aä]ltnis|Bildseitenverh[aä]ltnis|Bildformat|"
|
||||||
|
r"Videoformat|Hochformat|Querformat|Format|Aspect[- ]?Ratio)"
|
||||||
|
)
|
||||||
|
text = re.sub(
|
||||||
|
rf"\b({cue}\b(?:\s+(?:von|im|ist|betr[aä]gt))?\s*[\(\[]?\s*)"
|
||||||
|
rf"(\d{{1,3}})\s*:\s*(\d{{1,3}})",
|
||||||
|
lambda match: (
|
||||||
|
f"{match.group(1)}{int(match.group(2))} zu {int(match.group(3))}"
|
||||||
|
),
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
return re.sub(
|
||||||
|
rf"\b(\d{{1,3}})\s*:\s*(\d{{1,3}})"
|
||||||
|
rf"(\s*[-‐‑‒–—−]?\s*{cue}\b)",
|
||||||
|
lambda match: (
|
||||||
|
f"{int(match.group(1))} zu {int(match.group(2))}{match.group(3)}"
|
||||||
|
),
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def normalize_for_german_speech(text: str) -> str:
|
def normalize_for_german_speech(text: str) -> str:
|
||||||
"""Turn common visual notation into unambiguous spoken German."""
|
"""Turn common visual notation into unambiguous spoken German."""
|
||||||
text = re.sub(r"\bv\.\s*a\.", "vor allem", text, flags=re.IGNORECASE)
|
text = re.sub(r"\bv\.\s*a\.", "vor allem", text, flags=re.IGNORECASE)
|
||||||
@@ -279,10 +308,14 @@ def normalize_for_german_speech(text: str) -> str:
|
|||||||
_spoken_ipv4,
|
_spoken_ipv4,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
# A colon is ambiguous between an aspect ratio and a clock time. Resolve
|
||||||
|
# ratios first, but only when an explicit format cue is present.
|
||||||
|
text = _normalize_aspect_ratios(text)
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"\b([01]?\d|2[0-3]):([0-5]\d)\b",
|
r"\b([01]?\d|2[0-3]):([0-5]\d)\b(?:\s*Uhr\b)?",
|
||||||
lambda match: f"{int(match.group(1))} Uhr {int(match.group(2))}",
|
lambda match: f"{int(match.group(1))} Uhr {int(match.group(2))}",
|
||||||
text,
|
text,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"\b([01]?\d|2[0-3])\s*[-‐‑‒–—−]\s*"
|
r"\b([01]?\d|2[0-3])\s*[-‐‑‒–—−]\s*"
|
||||||
@@ -702,18 +735,6 @@ def synthesize_xtts(text: str, output_format: str,
|
|||||||
return _convert(_wav(_join_pcm(pcm_parts)), output_format, speed)
|
return _convert(_wav(_join_pcm(pcm_parts)), output_format, speed)
|
||||||
|
|
||||||
|
|
||||||
def synthesize_piper(text: str, output_format: str,
|
|
||||||
speed: float) -> tuple[bytes, str]:
|
|
||||||
upstream_format = "wav" if output_format == "pcm" else output_format
|
|
||||||
audio, content_type = _request(
|
|
||||||
f"{PIPER_URL}/tts",
|
|
||||||
payload={"text": text, "voice": "alloy", "speed": speed,
|
|
||||||
"format": upstream_format},
|
|
||||||
timeout=PIPER_TIMEOUT,
|
|
||||||
)
|
|
||||||
return _convert(audio, "pcm", 1.0) if output_format == "pcm" else (audio, content_type)
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize_qwen(text: str, output_format: str,
|
def synthesize_qwen(text: str, output_format: str,
|
||||||
speed: float) -> tuple[bytes, str]:
|
speed: float) -> tuple[bytes, str]:
|
||||||
text = prepare_for_qwen_speech(text)
|
text = prepare_for_qwen_speech(text)
|
||||||
@@ -728,6 +749,47 @@ def synthesize_qwen(text: str, output_format: str,
|
|||||||
return _convert(audio, "pcm", 1.0) if output_format == "pcm" else (audio, content_type)
|
return _convert(audio, "pcm", 1.0) if output_format == "pcm" else (audio, content_type)
|
||||||
|
|
||||||
|
|
||||||
|
def open_qwen_pcm_stream(text: str, chunk_size: int = 4) \
|
||||||
|
-> tuple[http.client.HTTPConnection, http.client.HTTPResponse]:
|
||||||
|
"""Open Qwen's native token-level PCM stream without buffering it.
|
||||||
|
|
||||||
|
The upstream emits headerless 24 kHz mono signed 16-bit little-endian
|
||||||
|
PCM. Keeping this response streaming is what lets playback begin while
|
||||||
|
the remainder of the sentence is still being synthesized.
|
||||||
|
"""
|
||||||
|
parsed = urllib.parse.urlparse(QWEN_TTS_URL)
|
||||||
|
if parsed.scheme != "http" or not parsed.hostname:
|
||||||
|
raise RuntimeError("QWEN_TTS_URL must be an http URL")
|
||||||
|
port = parsed.port or 80
|
||||||
|
prefix = parsed.path.rstrip("/")
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": QWEN_TTS_MODEL,
|
||||||
|
"input": prepare_for_qwen_speech(text),
|
||||||
|
"voice": QWEN_TTS_VOICE,
|
||||||
|
"language": QWEN_TTS_LANGUAGE,
|
||||||
|
"chunk_size": chunk_size,
|
||||||
|
}, separators=(",", ":")).encode()
|
||||||
|
connection = http.client.HTTPConnection(
|
||||||
|
parsed.hostname, port, timeout=QWEN_TTS_TIMEOUT)
|
||||||
|
try:
|
||||||
|
connection.request(
|
||||||
|
"POST",
|
||||||
|
f"{prefix}/v1/audio/speech/pcm-stream",
|
||||||
|
body=payload,
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Accept": "application/octet-stream"},
|
||||||
|
)
|
||||||
|
response = connection.getresponse()
|
||||||
|
if response.status != HTTPStatus.OK:
|
||||||
|
message = response.read(512).decode(errors="replace")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Qwen PCM stream failed ({response.status}): {message}")
|
||||||
|
return connection, response
|
||||||
|
except Exception:
|
||||||
|
connection.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def synthesize(text: str, output_format: str, speed: float) -> tuple[bytes, str]:
|
def synthesize(text: str, output_format: str, speed: float) -> tuple[bytes, str]:
|
||||||
acquired = SYNTHESIS_LOCK.acquire(timeout=QUEUE_TIMEOUT)
|
acquired = SYNTHESIS_LOCK.acquire(timeout=QUEUE_TIMEOUT)
|
||||||
if acquired:
|
if acquired:
|
||||||
@@ -737,22 +799,18 @@ def synthesize(text: str, output_format: str, speed: float) -> tuple[bytes, str]
|
|||||||
STATE["last_backend"] = "qwen3-tts-1.7b"
|
STATE["last_backend"] = "qwen3-tts-1.7b"
|
||||||
STATE["last_error"] = None
|
STATE["last_error"] = None
|
||||||
return audio
|
return audio
|
||||||
except Exception as exc: # fallback must cover all Qwen failures
|
except Exception as exc:
|
||||||
with STATE_LOCK:
|
with STATE_LOCK:
|
||||||
STATE["xtts_failures"] += 1
|
STATE["qwen_failures"] += 1
|
||||||
STATE["last_error"] = type(exc).__name__
|
STATE["last_error"] = type(exc).__name__
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
SYNTHESIS_LOCK.release()
|
SYNTHESIS_LOCK.release()
|
||||||
else:
|
else:
|
||||||
with STATE_LOCK:
|
with STATE_LOCK:
|
||||||
STATE["xtts_failures"] += 1
|
STATE["qwen_failures"] += 1
|
||||||
STATE["last_error"] = "queue-timeout"
|
STATE["last_error"] = "queue-timeout"
|
||||||
|
raise RuntimeError("speech queue timeout")
|
||||||
audio = synthesize_piper(text, output_format, speed)
|
|
||||||
with STATE_LOCK:
|
|
||||||
STATE["last_backend"] = "piper"
|
|
||||||
STATE["piper_fallbacks"] += 1
|
|
||||||
return audio
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
@@ -779,25 +837,26 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||||
return
|
return
|
||||||
primary_ready = _reachable(QWEN_TTS_URL, "/health")
|
primary_ready = _reachable(QWEN_TTS_URL, "/health")
|
||||||
fallback_ready = _reachable(PIPER_URL, "/status")
|
|
||||||
with STATE_LOCK:
|
with STATE_LOCK:
|
||||||
state = dict(STATE)
|
state = dict(STATE)
|
||||||
|
# This endpoint is also the container liveness check. Qwen3-TTS is
|
||||||
|
# deliberately stopped in exclusive GPU modes such as Applio, so the
|
||||||
|
# gateway itself must stay healthy while reporting ready=false.
|
||||||
self.send_json(
|
self.send_json(
|
||||||
HTTPStatus.OK if fallback_ready else HTTPStatus.SERVICE_UNAVAILABLE,
|
HTTPStatus.OK,
|
||||||
{
|
{
|
||||||
"ready": fallback_ready,
|
"ready": primary_ready,
|
||||||
"engine": "qwen3-tts-with-piper-fallback",
|
"engine": "qwen3-tts",
|
||||||
"model": "Qwen3-TTS-12Hz-1.7B-Base",
|
"model": "Qwen3-TTS-12Hz-1.7B-Base",
|
||||||
"voices": [VOICE_ALIAS],
|
"voices": [VOICE_ALIAS],
|
||||||
"speaker": QWEN_TTS_VOICE,
|
"speaker": QWEN_TTS_VOICE,
|
||||||
"primary_ready": primary_ready,
|
"primary_ready": primary_ready,
|
||||||
"fallback_ready": fallback_ready,
|
|
||||||
**state,
|
**state,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802
|
def do_POST(self) -> None: # noqa: N802
|
||||||
if self.path != "/tts":
|
if self.path not in {"/tts", "/tts/pcm-stream"}:
|
||||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -810,7 +869,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
request = json.loads(self.rfile.read(length))
|
request = json.loads(self.rfile.read(length))
|
||||||
text = request.get("text", "")
|
text = request.get("input", request.get("text", ""))
|
||||||
voice = request.get("voice", VOICE_ALIAS)
|
voice = request.get("voice", VOICE_ALIAS)
|
||||||
output_format = request.get("format", "mp3")
|
output_format = request.get("format", "mp3")
|
||||||
speed = float(request.get("speed", 1.0))
|
speed = float(request.get("speed", 1.0))
|
||||||
@@ -829,6 +888,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not 0.5 <= speed <= 2.0:
|
if not 0.5 <= speed <= 2.0:
|
||||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid speed"})
|
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid speed"})
|
||||||
return
|
return
|
||||||
|
if self.path == "/tts/pcm-stream":
|
||||||
|
self._stream_qwen_pcm(text.strip(), request)
|
||||||
|
return
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
audio, content_type = synthesize(text.strip(), output_format, speed)
|
audio, content_type = synthesize(text.strip(), output_format, speed)
|
||||||
@@ -836,13 +898,81 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
with STATE_LOCK:
|
with STATE_LOCK:
|
||||||
STATE["last_error"] = type(exc).__name__
|
STATE["last_error"] = type(exc).__name__
|
||||||
self.send_json(HTTPStatus.SERVICE_UNAVAILABLE,
|
self.send_json(HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
{"error": "all local speech backends failed"})
|
{"error": "local Qwen3-TTS backend failed"})
|
||||||
return
|
return
|
||||||
print(f"tts-gateway: synthesized via {STATE['last_backend']} in "
|
print(f"tts-gateway: synthesized via {STATE['last_backend']} in "
|
||||||
f"{time.monotonic() - started:.2f}s")
|
f"{time.monotonic() - started:.2f}s")
|
||||||
self.send_bytes(HTTPStatus.OK, audio, content_type)
|
self.send_bytes(HTTPStatus.OK, audio, content_type)
|
||||||
|
|
||||||
|
def _stream_qwen_pcm(self, text: str, request: dict) -> None:
|
||||||
|
"""Unframe Qwen's PCM frames and relay their audio immediately."""
|
||||||
|
try:
|
||||||
|
chunk_size = max(1, min(32, int(request.get("chunk_size", 4))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.send_json(HTTPStatus.BAD_REQUEST,
|
||||||
|
{"error": "invalid chunk_size"})
|
||||||
|
return
|
||||||
|
acquired = SYNTHESIS_LOCK.acquire(timeout=QUEUE_TIMEOUT)
|
||||||
|
if not acquired:
|
||||||
|
self.send_json(HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
{"error": "speech queue timeout"})
|
||||||
|
return
|
||||||
|
connection = None
|
||||||
|
started = time.monotonic()
|
||||||
|
headers_sent = False
|
||||||
|
try:
|
||||||
|
connection, response = open_qwen_pcm_stream(text, chunk_size)
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
headers_sent = True
|
||||||
|
first = True
|
||||||
|
while True:
|
||||||
|
frame_header = response.read(4)
|
||||||
|
if not frame_header:
|
||||||
|
break
|
||||||
|
if len(frame_header) != 4:
|
||||||
|
raise RuntimeError("truncated Qwen PCM frame header")
|
||||||
|
frame_length = int.from_bytes(frame_header, "big")
|
||||||
|
if frame_length == 0:
|
||||||
|
break
|
||||||
|
if frame_length > MAX_AUDIO_BYTES:
|
||||||
|
raise RuntimeError("Qwen PCM frame is too large")
|
||||||
|
remaining = frame_length
|
||||||
|
while remaining:
|
||||||
|
chunk = response.read(min(16384, remaining))
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("truncated Qwen PCM frame")
|
||||||
|
if first:
|
||||||
|
print("tts-gateway: first Qwen PCM chunk in "
|
||||||
|
f"{time.monotonic() - started:.2f}s")
|
||||||
|
first = False
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
self.wfile.flush()
|
||||||
|
remaining -= len(chunk)
|
||||||
|
with STATE_LOCK:
|
||||||
|
STATE["last_backend"] = "qwen3-tts-1.7b-stream"
|
||||||
|
STATE["last_error"] = None
|
||||||
|
except Exception as exc:
|
||||||
|
with STATE_LOCK:
|
||||||
|
STATE["last_error"] = type(exc).__name__
|
||||||
|
# Once PCM started, simply close the truncated response. Sending
|
||||||
|
# JSON into the audio stream would produce loud corrupt samples.
|
||||||
|
if not headers_sent and not self.wfile.closed:
|
||||||
|
try:
|
||||||
|
self.send_json(HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
{"error": "local PCM stream failed"})
|
||||||
|
except (OSError, BrokenPipeError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if connection is not None:
|
||||||
|
connection.close()
|
||||||
|
SYNTHESIS_LOCK.release()
|
||||||
|
self.close_connection = True
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print(f"TTS gateway ready on {HOST}:{PORT}; primary={QWEN_TTS_VOICE}; fallback=Piper")
|
print(f"TTS gateway ready on {HOST}:{PORT}; backend=Qwen3-TTS; voice={QWEN_TTS_VOICE}")
|
||||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
target=/opt/hermes/cron/scheduler_delivery.py
|
|
||||||
broken='env.pop("HERMES_HOME", None)'
|
|
||||||
|
|
||||||
if [ -f "$target" ] && grep -Fq "$broken" "$target"; then
|
|
||||||
sed -i '/^[[:space:]]*env\.pop("HERMES_HOME", None)[[:space:]]*$/d' "$target"
|
|
||||||
echo "[cron-profile-root-fix] preserved HERMES_HOME for named-profile delivery"
|
|
||||||
else
|
|
||||||
echo "[cron-profile-root-fix] upstream code already fixed or layout changed; no action"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cli_dir="${HERMES_HOME:-/opt/data}/.local/bin"
|
|
||||||
mkdir -p "$cli_dir"
|
|
||||||
ln -sfn /opt/hermes/.venv/bin/hermes "$cli_dir/hermes"
|
|
||||||
ln -sfn /opt/hermes/.venv/bin/hermes /usr/local/bin/hermes
|
|
||||||
|
|
||||||
# Hermes' generic sentence splitter treats periods in German abbreviations as
|
|
||||||
# sentence ends. It also submits every sentence as a separate generative TTS
|
|
||||||
# request, which creates long gaps with local Qwen3-TTS. Keep the first sentence
|
|
||||||
# immediate, then group following complete sentences into modest ~240-character
|
|
||||||
# chunks so playback remains responsive but substantially more continuous.
|
|
||||||
tts_target=/opt/hermes/tools/tts_streaming.py
|
|
||||||
if [ -f "$tts_target" ] && grep -Fq 'self.buf = _THINK_BLOCK_RE.sub("", self.buf + delta)' "$tts_target"; then
|
|
||||||
TTS_TARGET="$tts_target" python3 <<'PY'
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
p = Path(os.environ["TTS_TARGET"])
|
|
||||||
s = p.read_text()
|
|
||||||
|
|
||||||
base_feed = ' self.buf = _THINK_BLOCK_RE.sub("", self.buf + delta)'
|
|
||||||
abbr_lines = [
|
|
||||||
' self.buf = re.sub(r"\\bca\\.(?=\\s)", "circa", self.buf, flags=re.IGNORECASE)',
|
|
||||||
' self.buf = re.sub(r"\\bv\\.\\s*a\\.(?=\\s)", "vor allem", self.buf, flags=re.IGNORECASE)',
|
|
||||||
' self.buf = re.sub(r"\\bmax\\.(?=\\s)", "maximal", self.buf, flags=re.IGNORECASE)',
|
|
||||||
]
|
|
||||||
for line in abbr_lines:
|
|
||||||
s = s.replace("\n" + line, "")
|
|
||||||
|
|
||||||
init_old = ' self.buf = ""'
|
|
||||||
init_extra = (
|
|
||||||
'\n self.followup_target_len = 240'
|
|
||||||
'\n self._emitted_first = False'
|
|
||||||
)
|
|
||||||
s = s.replace(init_old + init_extra, init_old)
|
|
||||||
|
|
||||||
threshold_old = ' if len(head.strip()) < self.min_len:'
|
|
||||||
threshold_new = (
|
|
||||||
' threshold = (self.min_len if not self._emitted_first '
|
|
||||||
'else self.followup_target_len)\n'
|
|
||||||
' if len(head.strip()) < threshold:'
|
|
||||||
)
|
|
||||||
s = s.replace(threshold_new, threshold_old)
|
|
||||||
|
|
||||||
append_old = ' out.append(head)'
|
|
||||||
append_new = append_old + '\n self._emitted_first = True'
|
|
||||||
s = s.replace(append_new, append_old)
|
|
||||||
|
|
||||||
s = s.replace(base_feed, base_feed + "\n" + "\n".join(abbr_lines), 1)
|
|
||||||
s = s.replace(init_old, init_old + init_extra, 1)
|
|
||||||
s = s.replace(threshold_old, threshold_new, 1)
|
|
||||||
s = s.replace(append_old, append_new, 1)
|
|
||||||
p.write_text(s)
|
|
||||||
PY
|
|
||||||
echo "[tts-streaming-fix] enabled German normalization and adaptive sentence grouping"
|
|
||||||
else
|
|
||||||
echo "[tts-streaming-fix] upstream code already fixed or layout changed; no action"
|
|
||||||
fi
|
|
||||||
@@ -144,13 +144,12 @@ stt:
|
|||||||
model: "base"
|
model: "base"
|
||||||
language: "de"
|
language: "de"
|
||||||
|
|
||||||
# Reuse Athena's OpenAI-compatible TTS route. It currently serves XTTS v2 with
|
# Reuse Athena's OpenAI-compatible Qwen3-TTS route.
|
||||||
# Annmarie Nele and transparently falls back to Piper when XTTS is unavailable.
|
|
||||||
tts:
|
tts:
|
||||||
provider: "openai"
|
provider: "openai"
|
||||||
speed: 1.0
|
speed: 1.0
|
||||||
openai:
|
openai:
|
||||||
model: "piper"
|
model: "qwen3-tts"
|
||||||
voice: "alloy"
|
voice: "alloy"
|
||||||
speed: 1.0
|
speed: 1.0
|
||||||
base_url: "http://router:8081/v1"
|
base_url: "http://router:8081/v1"
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
---
|
|
||||||
name: athena-ai-profile-router
|
|
||||||
description: Use when operating the AI profile router on Athena.
|
|
||||||
metadata:
|
|
||||||
hermes:
|
|
||||||
editorial_name: "Athena AI-Profile-Router"
|
|
||||||
editorial_description: "Betrieb und Abfragen der lokalen KI-Plattform auf Athena: Profile, Status, Umschaltung."
|
|
||||||
---
|
|
||||||
|
|
||||||
# Athena AI-Profile-Router
|
|
||||||
|
|
||||||
Lokale KI-Plattform auf **Athena** (root@192.168.1.212, Debian 13, GPU-Host).
|
|
||||||
Repo: `/root/AI-Profile-Router` (Gitea: `michael/AI-Profile-Router`).
|
|
||||||
SSH: `ssh -i /opt/data/athena_key -o UserKnownHostsFile=/opt/data/.ssh/known_hosts -o IdentitiesOnly=yes root@192.168.1.212`
|
|
||||||
|
|
||||||
## Wichtigste Regel
|
|
||||||
|
|
||||||
**Der Host steht physisch in einer anderen Stadt. Es gibt keinen schnellen
|
|
||||||
Zugang.**
|
|
||||||
|
|
||||||
- **NIEMALS** herunterfahren (`shutdown`, `poweroff`, `halt`) oder neu
|
|
||||||
starten (`reboot`, `init 6`).
|
|
||||||
- Keine Aktion, die die Erreichbarkeit gefährdet: keine Änderungen an `lan0`,
|
|
||||||
Firewall-Default-Policies, `DOCKER-USER`-Regeln oder Routing ohne explizite
|
|
||||||
Freigabe und Rückfallplan; keine Reboot-Pflicht auslösenden Aktionen
|
|
||||||
(Kernel-, NVIDIA-Treiber-Installation, `apt full-upgrade` mit Kernel) ohne
|
|
||||||
ausdrückliche Freigabe; keine Abschaltung von WireGuard, SSH oder dem
|
|
||||||
Gateway-Container.
|
|
||||||
- Installer-Exit-Code 20 (NVIDIA-Treiber, Reboot nötig) wird nicht automatisch
|
|
||||||
nachgeholt — der Betreiber entscheidet.
|
|
||||||
- Nach jeder Netz-/Firewall-/WG-Änderung verifizieren: (1) SSH-Roundtrip,
|
|
||||||
(2) WG-Tunnel up, (3) Router `/health` und `/ready` = 200.
|
|
||||||
|
|
||||||
## Architektur (Kurzform)
|
|
||||||
|
|
||||||
- **Profile Router** (`mike-ai-router`, OpenAI-kompatible API, Port 8081):
|
|
||||||
einzige Client-Schnittstelle. Clients nutzen `http://<host>:8081/v1` mit
|
|
||||||
Bearer-Key aus `/etc/mike-ai/router-api-key`.
|
|
||||||
- **llama.cpp** (`mike-ai-llama-*`): ein Container pro Profil, immer exakt
|
|
||||||
einer aktiv; nur Docker-intern (Port 8080, nie direkt von Clients).
|
|
||||||
- **Profile Controller** (`mike-ai-profile-controller`): einziger Dienst mit
|
|
||||||
Docker-Socket; begrenzter Containerwechsel.
|
|
||||||
- **Open WebUI** (`mike-ai-open-webui`, Port 8080): einzige normale Oberfläche.
|
|
||||||
- **MCP-Tool-Stack** (`platform/mcp/`): getrennte Container für Web, HA, ARR,
|
|
||||||
Unraid — nur über internes `mike-ai-tools`-Netz.
|
|
||||||
- WireGuard-Isolation: KI-Dienste nur über WG-Adresse erreichbar,
|
|
||||||
fail-closed bei Tunnelausfall (Blackhole-Default-Route).
|
|
||||||
|
|
||||||
## Profile (Live-Stand 11.09.2026)
|
|
||||||
|
|
||||||
| Profil | Virtuelles Modell | Kontext | Zweck |
|
|
||||||
|---|---|---:|---|
|
|
||||||
| fast | `qwen-fast` | 76.800 | Alltag, Agenten, hohe Geschwindigkeit |
|
|
||||||
| medium | `qwen-medium` | 160.000 | mehr Kontext |
|
|
||||||
| large | `qwen-large` | 192.000 | lange Sitzungen |
|
|
||||||
| ultra | `qwen-ultra` | 262.144 | maximale Kontextlänge |
|
|
||||||
| uncensored | `qwen-uncensored` | 80.000 | unzensiert |
|
|
||||||
|
|
||||||
Aktives Profil: `GET /status` → `current_profile`.
|
|
||||||
Profilwechsel: `POST /fast`, `/medium`, `/large`, `/ultra`, `/uncensored`
|
|
||||||
(authentifiziert) oder manuell `llama-profile <name>` auf dem Host.
|
|
||||||
|
|
||||||
## Wichtige Befehle (per SSH auf Athena)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Status (Router lebt, Profil, Upstream, Telemetrie)
|
|
||||||
KEY=$(cat /etc/mike-ai/router-api-key)
|
|
||||||
curl -s -H "Authorization: Bearer ***" http://172.30.20.3:8081/status
|
|
||||||
|
|
||||||
# Health / Ready (ohne Auth)
|
|
||||||
curl -s -o /dev/null -w '%{http_code}' http://172.30.20.3:8081/health # 200 = Router lebt
|
|
||||||
curl -s -o /dev/null -w '%{http_code}' http://172.30.20.3:8081/ready # 200 = Modell bereit
|
|
||||||
|
|
||||||
# Virtuelle Modelle
|
|
||||||
curl -s -H "Authorization: Bearer ***" http://172.30.20.3:8081/v1/models
|
|
||||||
|
|
||||||
# Profilwechsel
|
|
||||||
curl -s -X POST -H "Authorization: Bearer ***" http://172.30.20.3:8081/ultra
|
|
||||||
|
|
||||||
# Aktive Profile-Registry (Router-Container)
|
|
||||||
docker exec mike-ai-router cat /app/router_profiles.json
|
|
||||||
|
|
||||||
# Container-Status
|
|
||||||
docker ps --format '{{.Names}}\t{{.Status}}' | grep mike-ai
|
|
||||||
```
|
|
||||||
|
|
||||||
Router-IP auf dem internen `mike-ai_control`-Netz: `172.30.20.3`
|
|
||||||
(neu ermitteln: `docker inspect mike-ai-router --format '{{.NetworkSettings.Networks.mike-ai_control.IPAddress}}'`).
|
|
||||||
|
|
||||||
## Fehler- und Recovery-Verhalten
|
|
||||||
|
|
||||||
- `/health=200` + `/ready=503` → Router lebt, Modell lädt/wechselt noch.
|
|
||||||
- `429` → Parallelitätsgrenze (max. 16) erreicht; Client mit Backoff.
|
|
||||||
- Profilwechsel bricht ab, wenn laufende Chats den Drain-Timeout (600 s)
|
|
||||||
überschreiten — er beendet niemals absichtlich einen Chat.
|
|
||||||
- Nach Routerabsturz: letztes stabiles Profil aus atomarer Zustandsdatei
|
|
||||||
(`/var/lib/mike-ai-profile-router/state.json`) rekonstruiert.
|
|
||||||
- Upgrade-Regel: niemals Build, Quantisierung und Profil gleichzeitig ändern.
|
|
||||||
|
|
||||||
## Git / Repo
|
|
||||||
|
|
||||||
- Remote: `git@192.168.1.2:michael/AI-Profile-Router.git` (Gitea auf Unraid).
|
|
||||||
- Athena erreicht das Heimnetz **nur über WireGuard** — ist der WG-Tunnel
|
|
||||||
down, schlägt `git push` mit Connection timed out fehl. Nicht mit
|
|
||||||
Firewall-/Routing-Änderungen gegensteuern; Tunnel-Status prüfen und
|
|
||||||
melden. Commits bleiben lokal auf Athena, bis der Tunnel wieder up ist.
|
|
||||||
- Git-Identität auf Athena ist repo-lokal gesetzt (Mikei386).
|
|
||||||
|
|
||||||
## Sicherheitsregeln
|
|
||||||
|
|
||||||
- API-Key (`/etc/mike-ai/router-api-key`) nie in Chat, Repo oder URLs.
|
|
||||||
- Clients nie direkt Port 8080 (llama.cpp) — immer über Router 8081.
|
|
||||||
- `config/install.env` bleibt lokal (0600), wird nicht committet.
|
|
||||||
- Systempartition dauerhaft unter 85 % halten; nur ein Textmodell gleichzeitig.
|
|
||||||
|
|
||||||
## Backup
|
|
||||||
|
|
||||||
Gesichert: Repo, Modellmanifest (Hashes, ohne Secrets), `/etc/mike-ai`
|
|
||||||
verschlüsselt, systemd-Konfiguration, Benchmarkresultate.
|
|
||||||
Nicht nötig: Builds, Venvs, Caches, Modelle (Quelle + Prüfsumme dokumentiert).
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Athena architecture and modes
|
||||||
|
|
||||||
|
Use `ATHENA.md` as the short operational truth and
|
||||||
|
`docs/CONTAINER_INVENTORY.md` for the current mapping of container, model and
|
||||||
|
role. If live state disagrees with documentation, report the discrepancy and
|
||||||
|
correct the durable source when the user requested maintenance.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Hermes, chats, skills and portable specialist MCPs live on Unraid.
|
||||||
|
- Athena is the inference host. Its canonical checkout is
|
||||||
|
`/opt/mike-ai/stack`; models live under `/data/models`; local secrets and
|
||||||
|
runtime configuration live under `/etc/mike-ai` and never enter Git.
|
||||||
|
- The Profile Router is the single OpenAI-compatible address clients use.
|
||||||
|
- The Profile Controller is the only component allowed to orchestrate approved
|
||||||
|
model and specialist workers.
|
||||||
|
- The Athena Operator is host-bound and is the normal maintenance interface.
|
||||||
|
|
||||||
|
## Exclusive states
|
||||||
|
|
||||||
|
Athena has five mutually exclusive persistent modes: `llm`, `music`,
|
||||||
|
`separation`, `voice`, and `voicechange`. Image generation is a transactional
|
||||||
|
request: it temporarily pauses the active text profile and Qwen3-TTS, runs the
|
||||||
|
image worker, then restores the previous LLM state.
|
||||||
|
|
||||||
|
Only one heavy GPU path may be active. Do not manually start a second GPU
|
||||||
|
worker around the controller. The lightweight dashboard, router, controller,
|
||||||
|
gateway, UI, CPU-STT, backup and operator containers may remain active.
|
||||||
|
|
||||||
|
## Model profiles
|
||||||
|
|
||||||
|
- Fast: Qwen3.8-27B IQ4-MIX, 76,800 tokens.
|
||||||
|
- Medium: Qwen3.8-27B IQ4_XS-pure, 160,000 tokens, vision.
|
||||||
|
- Large: the same Q4 model, 192,000 tokens, vision.
|
||||||
|
- Ultra: the same Q4 model, 262,144 tokens, no vision projector.
|
||||||
|
- Uncensored: Abliterated Q4_K_M, 80,000 tokens, vision.
|
||||||
|
|
||||||
|
Medium, Large, Ultra and Beta distribute their runtime across both GPUs. Do not
|
||||||
|
infer allocation from model size alone; verify the profile's Compose arguments
|
||||||
|
and live VRAM. RTX 3060 also hosts Qwen3-TTS during normal LLM operation.
|
||||||
|
|
||||||
|
## What is and is not stale
|
||||||
|
|
||||||
|
The GPU workers use `restart: "no"` and are created once, then started on
|
||||||
|
demand. A stopped `mike-ai-llama-*`, image, music, separator, OmniVoice or X-VC
|
||||||
|
container is expected. A candidate is stale only after checking Compose,
|
||||||
|
labels, mounts, router/controller references, model paths and test history.
|
||||||
@@ -163,10 +163,10 @@ log "Tool-Container mit der neuen Stackdefinition aktivieren"
|
|||||||
log "Router und OpenWebUI mit den wiederhergestellten Schlüsseln neu erstellen"
|
log "Router und OpenWebUI mit den wiederhergestellten Schlüsseln neu erstellen"
|
||||||
cd "$STACK_DIR"
|
cd "$STACK_DIR"
|
||||||
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --force-recreate \
|
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --force-recreate \
|
||||||
qwen3-tts piper tts-gateway router open-webui
|
qwen3-tts tts-gateway router open-webui
|
||||||
|
|
||||||
deadline=$((SECONDS + 180))
|
deadline=$((SECONDS + 180))
|
||||||
for container in mike-ai-qwen3-tts mike-ai-piper mike-ai-tts-gateway \
|
for container in mike-ai-qwen3-tts mike-ai-tts-gateway \
|
||||||
mike-ai-router mike-ai-open-webui; do
|
mike-ai-router mike-ai-open-webui; do
|
||||||
until [[ $(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
|
until [[ $(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
|
||||||
"$container" 2>/dev/null || true) == healthy ]]; do
|
"$container" 2>/dev/null || true) == healthy ]]; do
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ with con:
|
|||||||
for key, value in (
|
for key, value in (
|
||||||
("task.follow_up.enable", False),
|
("task.follow_up.enable", False),
|
||||||
("audio.tts.engine", "openai"),
|
("audio.tts.engine", "openai"),
|
||||||
("audio.tts.model", "piper"),
|
("audio.tts.model", "qwen3-tts"),
|
||||||
("audio.tts.voice", "alloy"),
|
("audio.tts.voice", "alloy"),
|
||||||
("audio.tts.openai.api_base_url", "http://router:8081/v1"),
|
("audio.tts.openai.api_base_url", "http://router:8081/v1"),
|
||||||
("web.search.enable", True),
|
("web.search.enable", True),
|
||||||
|
|||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuild/create specialist containers after a bare-metal restore. GPU workers
|
||||||
|
# remain stopped; the core installer leaves Athena safely in LLM mode.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
log() { printf '\n==> %s\n' "$*"; }
|
||||||
|
|
||||||
|
if [[ -r /etc/mike-ai/install.env ]]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source /etc/mike-ai/install.env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
export VOICE_GPU_UUID=${VOICE_GPU_UUID:-${IMAGE_GPU_DEVICES:-}}
|
||||||
|
export ACESTEP_GPU_UUID=${ACESTEP_GPU_UUID:-${IMAGE_GPU_DEVICES:-}}
|
||||||
|
export SEPARATOR_GPU_UUID=${SEPARATOR_GPU_UUID:-${IMAGE_GPU_DEVICES:-}}
|
||||||
|
|
||||||
|
create_project() {
|
||||||
|
local dir=$1 file=${2:-compose.yaml} profile=${3:-}
|
||||||
|
[[ -f $dir/$file ]] || { printf 'Übersprungen (fehlt): %s/%s\n' "$dir" "$file"; return 0; }
|
||||||
|
log "Spezialprojekt vorbereiten: $dir"
|
||||||
|
local args=(docker compose -f "$file")
|
||||||
|
[[ -z $profile ]] || args+=(--profile "$profile")
|
||||||
|
(cd "$dir" && "${args[@]}" pull --ignore-buildable && \
|
||||||
|
"${args[@]}" build && "${args[@]}" create)
|
||||||
|
}
|
||||||
|
|
||||||
|
docker network inspect mike-ai_frontend >/dev/null
|
||||||
|
create_project /opt/mike-ai/acestep-test compose.yaml music-test
|
||||||
|
create_project /opt/mike-ai/stem-separator
|
||||||
|
create_project /opt/mike-ai/omnivoice-studio
|
||||||
|
create_project /opt/mike-ai/xvc-studio
|
||||||
|
create_project /opt/mike-ai/stack/experiments/applio-rvc
|
||||||
|
create_project /opt/mike-ai/Mikes-Applio-UI compose.example.yaml
|
||||||
|
|
||||||
|
printf 'ATHENA_SPECIALISTS_REBUILT_OK\n'
|
||||||
@@ -7,9 +7,9 @@ SERVICE="${LLAMA_SERVICE:-mike-ai-llama-ui.service}"
|
|||||||
PROFILE="${1:-}"
|
PROFILE="${1:-}"
|
||||||
|
|
||||||
case "$PROFILE" in
|
case "$PROFILE" in
|
||||||
fast|medium|beta1|large|ultra|uncensored) ;;
|
fast|medium|large|ultra|uncensored) ;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: llama-profile {fast|medium|beta1|large|ultra|uncensored}" >&2
|
echo "Usage: llama-profile {fast|medium|large|ultra|uncensored}" >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+16
-4
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Restore Athena's non-reproducible Docker state from the latest /data backup.
|
# Restore Athena's configuration, deployed source and non-reproducible Docker
|
||||||
|
# state from a local quick-recovery archive.
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ backup_root=$work/backup
|
|||||||
[[ -d $backup_root/volumes ]] || die "Backup enthält keine Docker-Volumes."
|
[[ -d $backup_root/volumes ]] || die "Backup enthält keine Docker-Volumes."
|
||||||
|
|
||||||
# Stop only users of the restored volumes. WireGuard, SSH and networking stay up.
|
# Stop only users of the restored volumes. WireGuard, SSH and networking stay up.
|
||||||
for container in mike-ai-router mike-ai-profile-controller mike-ai-piper mike-ai-portainer; do
|
for container in mike-ai-router mike-ai-profile-controller mike-ai-portainer; do
|
||||||
if [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true) == true ]]; then
|
if [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true) == true ]]; then
|
||||||
docker stop "$container" >/dev/null
|
docker stop "$container" >/dev/null
|
||||||
fi
|
fi
|
||||||
@@ -69,6 +70,16 @@ done
|
|||||||
install -d -m 0700 /etc/mike-ai
|
install -d -m 0700 /etc/mike-ai
|
||||||
rsync -a --delete "$backup_root/etc-mike-ai/" /etc/mike-ai/
|
rsync -a --delete "$backup_root/etc-mike-ai/" /etc/mike-ai/
|
||||||
|
|
||||||
|
# New archives contain all deployed projects. Accept the old stack-only layout
|
||||||
|
# as well so every already existing backup stays usable.
|
||||||
|
if [[ -d $backup_root/opt-mike-ai ]]; then
|
||||||
|
install -d -m 0755 /opt/mike-ai
|
||||||
|
rsync -a "$backup_root/opt-mike-ai/" /opt/mike-ai/
|
||||||
|
elif [[ -d $backup_root/stack ]]; then
|
||||||
|
install -d -m 0755 /opt/mike-ai/stack
|
||||||
|
rsync -a "$backup_root/stack/" /opt/mike-ai/stack/
|
||||||
|
fi
|
||||||
|
|
||||||
restore_volume() {
|
restore_volume() {
|
||||||
local volume=$1 source=$2 mountpoint
|
local volume=$1 source=$2 mountpoint
|
||||||
[[ -d $source ]] || return 0
|
[[ -d $source ]] || return 0
|
||||||
@@ -78,12 +89,13 @@ restore_volume() {
|
|||||||
rsync -a --delete "$source/" "$mountpoint/"
|
rsync -a --delete "$source/" "$mountpoint/"
|
||||||
}
|
}
|
||||||
|
|
||||||
restore_volume mike-ai_piper-data "$backup_root/volumes/piper-data"
|
|
||||||
restore_volume mike-ai_router-state "$backup_root/volumes/router-state"
|
restore_volume mike-ai_router-state "$backup_root/volumes/router-state"
|
||||||
restore_volume mike-ai_router-images "$backup_root/volumes/router-images"
|
restore_volume mike-ai_router-images "$backup_root/volumes/router-images"
|
||||||
restore_volume portainer_data "$backup_root/volumes/portainer-data"
|
restore_volume portainer_data "$backup_root/volumes/portainer-data"
|
||||||
|
|
||||||
cd "$ROOT_DIR"
|
deploy_root=/opt/mike-ai/stack
|
||||||
|
[[ -x $deploy_root/manage.sh ]] || deploy_root=$ROOT_DIR
|
||||||
|
cd "$deploy_root"
|
||||||
./manage.sh deploy core
|
./manage.sh deploy core
|
||||||
|
|
||||||
printf 'ATHENA_RESTORE_OK %s\n' "$ARCHIVE"
|
printf 'ATHENA_RESTORE_OK %s\n' "$ARCHIVE"
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
"context": 160000,
|
"context": 160000,
|
||||||
"model_alias": "qwen-medium"
|
"model_alias": "qwen-medium"
|
||||||
},
|
},
|
||||||
"beta1": {
|
|
||||||
"context": 192000,
|
|
||||||
"model_alias": "qwen-beta-1"
|
|
||||||
},
|
|
||||||
"large": {
|
"large": {
|
||||||
"context": 192000,
|
"context": 192000,
|
||||||
"model_alias": "qwen-large"
|
"model_alias": "qwen-large"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def main() -> int:
|
|||||||
speech = request(
|
speech = request(
|
||||||
"/audio/speech",
|
"/audio/speech",
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"model": "piper",
|
"model": "qwen3-tts",
|
||||||
"voice": "alloy",
|
"voice": "alloy",
|
||||||
"response_format": "wav",
|
"response_format": "wav",
|
||||||
"input": TEST_TEXT,
|
"input": TEST_TEXT,
|
||||||
|
|||||||
+8
-2
@@ -27,7 +27,6 @@ for name in \
|
|||||||
mike-ai-wireguard-gateway \
|
mike-ai-wireguard-gateway \
|
||||||
mike-ai-profile-controller \
|
mike-ai-profile-controller \
|
||||||
mike-ai-router \
|
mike-ai-router \
|
||||||
mike-ai-piper \
|
|
||||||
mike-ai-qwen3-tts \
|
mike-ai-qwen3-tts \
|
||||||
mike-ai-tts-gateway \
|
mike-ai-tts-gateway \
|
||||||
mike-ai-llama-dashboard \
|
mike-ai-llama-dashboard \
|
||||||
@@ -37,8 +36,15 @@ for name in \
|
|||||||
done
|
done
|
||||||
pass "Athena-Kerndienste sind gesund"
|
pass "Athena-Kerndienste sind gesund"
|
||||||
|
|
||||||
|
for name in mike-ai-llama-dashboard mike-ai-portainer; do
|
||||||
|
network_mode=$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$name" 2>/dev/null || true)
|
||||||
|
[[ $network_mode != container:* ]] || \
|
||||||
|
fail "$name teilt noch einen fluechtigen Container-Netzwerk-Namespace"
|
||||||
|
done
|
||||||
|
pass "Dashboard und Portainer besitzen stabile Netzwerk-Namespaces"
|
||||||
|
|
||||||
active_llama=$(docker ps --format '{{.Names}}' | \
|
active_llama=$(docker ps --format '{{.Names}}' | \
|
||||||
grep -Ec '^mike-ai-llama-(fast|medium|beta1|large|ultra|uncensored)$' || true)
|
grep -Ec '^mike-ai-llama-(fast|medium|large|ultra|uncensored)$' || true)
|
||||||
[[ $active_llama -eq 1 ]] || fail "$active_llama aktive llama-Profile (erwartet: 1)"
|
[[ $active_llama -eq 1 ]] || fail "$active_llama aktive llama-Profile (erwartet: 1)"
|
||||||
pass "Genau ein llama.cpp-Profil ist aktiv"
|
pass "Genau ein llama.cpp-Profil ist aktiv"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user