# Deployment-Prompt: Voice Assistant auf jamulix.de Dieses Dokument beschreibt alle Änderungen, die am Repository `my_voice_assistant_v3` für den Betrieb auf **jamulix.de** vorgenommen wurden. Ziel: Ein zweites Claude-Instanz (oder ein Mensch) soll die Änderungen exakt reproduzieren können. --- ## Kontext - **Server:** jamulix.de, Ubuntu 24.04 LTS, 4 CPU-Kerne (QEMU), 3,8 GB RAM - **Kein GPU** — Chatterbox ist nicht nutzbar - **Installationspfad:** `/opt/voice-assistant` - **Systembenutzer:** `voice` (GID 22, bereits vorhanden als Gruppe) - **Konfigurationsverzeichnis:** `/etc/voice-assistant/` - **Datenbank:** `/var/lib/voice-assistant/` - **Profil:** `jamulix` (STT lokal, LLM Cloud, TTS lokal) - **URL:** `https://voice.jamulix.de` (Subdomain, eigenes SSL-Zertifikat) - **Authentifizierung:** Authelia 2FA (bereits auf Server installiert, läuft auf Port 9091) --- ## Teil 1: Repository-Änderungen (Code) ### 1.1 `app/api/config.py` — vollständig ersetzen Die originale Datei meldet keine Verfügbarkeit von Chatterbox oder Cloud-TTS. Ersetze den gesamten Inhalt durch: ```python import httpx from fastapi import APIRouter from app.config import settings, active_profile from app.dependencies import ( resolve_route, get_audio_router, STT_REGISTRY, LLM_REGISTRY, TTS_REGISTRY, ) router = APIRouter() async def _chatterbox_reachable() -> bool: try: async with httpx.AsyncClient(timeout=1.5) as client: r = await client.get(f"{settings.chatterbox_base_url.rstrip('/')}/status") return r.status_code < 500 except Exception: return False async def _openrouter_tts_available() -> bool: """Prüft ob das konfigurierte TTS-Modell auf OpenRouter via /audio/speech erreichbar ist.""" key = settings.openrouter_api_key.strip() model = settings.openrouter_tts_model.strip() if not key or not model: return False try: async with httpx.AsyncClient(timeout=5.0) as client: r = await client.post( "https://openrouter.ai/api/v1/audio/speech", headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, json={"model": model, "input": "Test", "voice": settings.openrouter_tts_voice or "alloy", "response_format": "mp3"}, ) if r.status_code == 200: return True body = r.text return "does not exist" not in body and r.status_code < 500 except Exception: return False @router.get("/config") async def get_config(): """Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine.""" route = resolve_route() audio_router = get_audio_router() chatterbox_ok, openrouter_tts_ok = await _chatterbox_reachable(), await _openrouter_tts_available() return { "profile": active_profile(), "app_env": settings.app_env, "default_route": route.as_dict(), "available": { "stt_providers": sorted(STT_REGISTRY), "llm_providers": sorted(LLM_REGISTRY), "tts_providers": sorted(TTS_REGISTRY), "input_endpoints": [c.model_dump() for c in await audio_router.list_inputs()], "output_endpoints": [c.model_dump() for c in await audio_router.list_outputs()], "chatterbox_reachable": chatterbox_ok, "openrouter_tts_available": openrouter_tts_ok, }, "secrets": { "openrouter_api_key_set": bool(settings.openrouter_api_key.strip()), }, } ``` **Zweck:** Das Frontend kann damit nicht verfügbare TTS-Optionen automatisch ausgrauen, ohne dass der Nutzer erst auf einen Fehler stößt. --- ### 1.2 `app/web/app.js` — Chatterbox/OpenRouter Verfügbarkeits-Refresh #### A) Zwei Funktionen nach `refreshDevicePreset()` einfügen Suche in `app.js` die Funktion `refreshDevicePreset()`. Sie endet mit: ```javascript syncPresetUI(); } ``` Füge **direkt danach** (vor `// Markiert das aktive Ton-Preset`) diese beiden Funktionen ein: ```javascript // Graut "Hohe Qualität" (Chatterbox) aus, wenn kein GPU-Backend erreichbar ist. async function refreshChatterboxPreset() { const btn = document.querySelector('.tts-preset[data-tts="chatterbox"]'); if (!btn || !ttsSel) return; try { const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); const available = cfg.available?.chatterbox_reachable === true; btn.disabled = !available; btn.classList.toggle("opacity-50", !available); btn.classList.toggle("cursor-not-allowed", !available); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = available ? "Chatterbox · natürliche Stimme" : "Chatterbox · kein GPU-Backend erreichbar"; if (!available && ttsSel.value === "chatterbox") { ttsSel.value = "piper"; syncPresetUI(); } } catch (e) { /* ignorieren */ } } // Graut "Cloud" (OpenRouter TTS) aus, wenn das TTS-Modell nicht verfügbar ist. async function refreshOpenrouterTtsPreset() { const btn = document.querySelector('.tts-preset[data-tts="openrouter"]'); if (!btn || !ttsSel) return; try { const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); const available = cfg.available?.openrouter_tts_available === true; btn.disabled = !available; btn.classList.toggle("opacity-50", !available); btn.classList.toggle("cursor-not-allowed", !available); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = available ? "OpenRouter · benötigt Internet" : "OpenRouter · kein TTS-Modell verfügbar"; if (!available && ttsSel.value === "openrouter") { ttsSel.value = "piper"; syncPresetUI(); } } catch (e) { /* ignorieren */ } } ``` #### B) Zwei Aufrufe nach `loadMe()` einfügen Suche die Zeile `loadMe();`. Füge **direkt danach** ein: ```javascript refreshChatterboxPreset(); refreshOpenrouterTtsPreset(); ``` --- ### 1.3 `app/web/app.js` — Sprachen und Geschlechtspräferenz #### A) `LANG_BCP47`-Map anpassen `nl` entfernen, `pt` (Brasilianisch), `pl` (Polnisch) und `ar` (Arabisch) ergänzen: ```javascript const LANG_BCP47 = { de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES", it: "it-IT", pt: "pt-BR", pl: "pl-PL", ar: "ar-SA", ru: "ru-RU", zh: "zh-CN", }; ``` #### B) Admin-Panel `default_language` anpassen ```javascript default_language: { ui: "combo", opts: ["de","en","fr","es","it","pt","pl","ar"], labels: { de:"Deutsch", en:"Englisch", fr:"Französisch", es:"Spanisch", it:"Italienisch", pt:"Portugiesisch", pl:"Polnisch", ar:"Arabisch" } }, ``` #### C) Admin-Panel `piper_voice` — vollständige Liste ```javascript piper_voice: { ui: "combo", opts: [ "de_DE-thorsten-high","de_DE-kerstin-low", "en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high", "fr_FR-siwis-medium","fr_FR-tom-medium", "es_ES-sharvard-medium", "it_IT-paola-medium","it_IT-riccardo-x_low", "pt_BR-faber-medium", "pl_PL-gosia-medium","pl_PL-darkman-medium", "ar_JO-kareem-medium", "ru_RU-irina-medium","ru_RU-ruslan-medium", "zh_CN-huayan-medium" ], test: "tts", testProvider: "piper" }, ``` #### D) Geschlechtspräferenz — globale Variable und Hilfsfunktion Direkt vor `function overrides()` einfügen: ```javascript // Geschlechtspräferenz: 'm' oder 'f' — Radiobutton-Logik, immer genau einer aktiv let _voiceGender = 'f'; function applyGenderChoice(gender) { // 'any' aus alten Prefs → weiblich als Standardwert _voiceGender = (gender === 'm') ? 'm' : 'f'; const btnM = document.getElementById('gender-m'); const btnF = document.getElementById('gender-f'); if (!btnM || !btnF) return; const mActive = (_voiceGender === 'm'); const fActive = (_voiceGender === 'f'); // Aktiv: blauer Hintergrund + weiße Schrift // Inaktiv: text-slate-700 explizit, damit kein Vererbungsproblem in Tag-Modus btnM.classList.toggle('bg-blue-600', mActive); btnM.classList.toggle('text-white', mActive); btnM.classList.toggle('border-blue-600', mActive); btnM.classList.toggle('text-slate-700', !mActive); btnF.classList.toggle('bg-blue-600', fActive); btnF.classList.toggle('text-white', fActive); btnF.classList.toggle('border-blue-600', fActive); btnF.classList.toggle('text-slate-700', !fActive); } ``` #### E) `overrides()` — `voice_gender` mitsenden ```javascript function overrides() { const out = { ...langOverrides() }; if (isDeviceMode()) { out.text_only = true; } else if (ttsSel && ttsSel.value) { out.tts_provider = ttsSel.value; } out.voice_gender = _voiceGender; return out; } ``` #### F) `loadMe()` — Präferenz laden In der `loadMe()`-Funktion direkt nach `applyPlaybackChoice(prefs.tts_provider)`: ```javascript applyGenderChoice(prefs.voice_gender || 'f'); ``` #### G) Gender-Button Event-Listener Nach dem `ttsSel`-Listener und vor `loadMe()` einfügen. Radiobutton-Logik: Klick setzt immer direkt auf m oder f, kein Toggle zurück auf "any": ```javascript document.addEventListener("DOMContentLoaded", () => { const btnM = document.getElementById("gender-m"); const btnF = document.getElementById("gender-f"); if (btnM) btnM.addEventListener("click", () => { applyGenderChoice("m"); savePrefs({ voice_gender: "m" }); }); if (btnF) btnF.addEventListener("click", () => { applyGenderChoice("f"); savePrefs({ voice_gender: "f" }); }); }); ``` #### H) Nachrichten-Bubbles: RTL-Unterstützung In `addMessage()` nach `span.className = "bubble-text";`: ```javascript span.dir = "auto"; ``` --- ### 1.4 `app/web/index.html` — Sprachen und Geschlechts-UI #### A) `words-lang`-Select (Wörter-Trainer): `nl` → `ar`, PT und PL ergänzen ```html ``` #### B) `lang-sel`-Select (Sprach-Auswahl oben): `nl` → `ar`, PT und PL ergänzen ```html ``` #### C) Texteingabe-Feld: automatische Schreibrichtung ```html ``` #### D) Gender-Buttons in der Sidebar Direkt vor `` einfügen. `text-slate-700` muss in der Basis-Klasse stehen, damit Tailwind JIT die Farbe generiert (inaktive Buttons bekommen sie per JS, aktive `text-white` überschreibt sie): ```html

Stimme

``` --- ### 1.5 `app/providers/tts/piper.py` — Speaker-ID-Unterstützung ergänzen Piper-Modelle können mehrere Sprecher enthalten (z. B. `es_ES-sharvard-medium` mit Speaker `M=0` und `F=1`). Die Stimmauswahl nutzt das Format `stimmname#speaker_id`, z. B. `es_ES-sharvard-medium#0`. Drei Stellen in `piper.py` müssen angepasst werden: #### A) Neue statische Methode `_parse_speaker()` in `PiperTTSProvider` ```python @staticmethod def _parse_speaker(voice: str | None) -> tuple[str | None, int | None]: """Trennt 'stimme#speaker_id' auf. Liefert (stimme, int_id) oder (stimme, None).""" if voice and "#" in voice: name, sid = voice.rsplit("#", 1) try: return name, int(sid) except ValueError: pass return voice, None ``` #### B) `synthesize()` — `_parse_speaker` aufrufen ```python async def synthesize(self, text, voice=None, audio_format="pcm", language=None): if not text or not text.strip(): raise ValueError("TTS input text is empty") voice_name, speaker_id = self._parse_speaker(voice) model, config = self._model_paths(voice_name) pcm, native_rate = await asyncio.to_thread( self._synthesize_sync, str(model), str(config), text.strip(), speaker_id ) ... ``` #### C) `_synthesize_sync()` — `speaker_id` via `SynthesisConfig` übergeben **Wichtig:** Die installierte `piper`-Library (Stand 2026-06) kennt kein `speaker_id`-Argument direkt — der Parameter wird über `piper.config.SynthesisConfig` übergeben: ```python def _synthesize_sync(self, model, config, text, speaker_id=None): if _PIPER_LIB: from piper.config import SynthesisConfig piper_voice = _load_voice(model) syn_cfg = SynthesisConfig(speaker_id=speaker_id) if speaker_id is not None else None pcm = bytearray() rate = self.target_rate for chunk in piper_voice.synthesize(text, syn_config=syn_cfg): pcm += chunk.audio_int16_bytes rate = chunk.sample_rate if not pcm: raise RuntimeError("Piper lieferte kein Audio") return bytes(pcm), rate return self._run_piper_binary(model, config, text, speaker_id) ``` #### D) `_run_piper_binary()` — `--speaker`-Flag ergänzen ```python def _run_piper_binary(self, model, config, text, speaker_id=None): import subprocess ... cmd = [self.bin_path, "-m", model, "-c", config, "--output-raw"] if speaker_id is not None: cmd += ["--speaker", str(speaker_id)] proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True) ... ``` --- ### 1.6 `app/providers/tts/cartesia.py` — vollständig ersetzen ```python import httpx from app.providers.tts.base import TTSProvider _API_URL = "https://api.cartesia.ai/tts/bytes" _API_VERSION = "2024-06-10" # Sprachen, die sonic-turbo unterstützt (Stand 2026-06). _LANG_MAP = { "de": "de", "en": "en", "fr": "fr", "es": "es", "zh": "zh", "cmn": "zh", "pt": "pt", "ja": "ja", "ko": "ko", "hi": "hi", } # sonic-turbo unterstützt diese Sprachen nicht — Fallback auf Piper empfohlen. _UNSUPPORTED_LANGS = {"ar", "it", "ru", "nl", "pl", "sv", "tr"} _LANG_NAMES = { "ar": "Arabisch", "it": "Italienisch", "ru": "Russisch", "nl": "Niederländisch", "pl": "Polnisch", "sv": "Schwedisch", "tr": "Türkisch", } _GENDER_CODES = {"m", "f", "any"} def _parse_voice_map(voices_str: str) -> dict[tuple[str, str], dict]: """Parst 'lang:gender:uuid:name'-Zeilen in ein (lang,gender)→{uuid,name}-Dict.""" result = {} for line in (voices_str or "").splitlines(): line = line.strip() if not line or line.startswith("#"): continue parts = line.split(":", 3) if len(parts) != 4: continue lang, gender, uuid, name = [p.strip() for p in parts] if lang and gender in ("m", "f") and uuid: result[(lang, gender)] = {"uuid": uuid, "name": name} return result class CartesiaTTSProvider(TTSProvider): def __init__( self, api_key: str, voice_id: str, model: str = "sonic-turbo", sample_rate: int = 24000, voices_str: str = "", ): self.api_key = (api_key or "").strip() self.default_voice_id = (voice_id or "").strip() self.model = (model or "sonic-turbo").strip() self.sample_rate = int(sample_rate) self.voice_map = _parse_voice_map(voices_str) def _resolve_voice_id(self, language: str | None, gender_hint: str | None) -> str: """Wählt UUID aus Voice-Map. Fallback-Kette: f→m→any→default_voice_id.""" lang = (language or "de").lower() gender = (gender_hint or "any").strip().lower() # "any" und "f" → weiblich bevorzugt if gender in ("any", "f"): candidates = ["f", "m"] else: candidates = ["m", "f"] for g in candidates: entry = self.voice_map.get((lang, g)) if entry: return entry["uuid"] return self.default_voice_id async def synthesize( self, text: str, voice: str | None = None, audio_format: str = "pcm", language: str | None = None, ) -> bytes: if not self.api_key: raise ValueError("CARTESIA_API_KEY is empty") if not text or not text.strip(): raise ValueError("TTS input text is empty") # voice ist entweder ein Geschlechts-Code ("m"/"f"/"any") oder eine direkte UUID if voice and voice not in _GENDER_CODES: voice_id = voice else: voice_id = self._resolve_voice_id(language, voice) if not voice_id: raise ValueError( "CARTESIA_VOICE_ID ist nicht konfiguriert — bitte UUID in voice-assistant.toml eintragen" ) lang_key = (language or "de").lower() if lang_key in _UNSUPPORTED_LANGS: lang_label = _LANG_NAMES.get(lang_key, lang_key) raise RuntimeError( f"Cartesia unterstützt {lang_label} ({lang_key.upper()}) noch nicht als TTS-Sprache" " — bitte Piper oder einen anderen Provider wählen" ) lang_code = _LANG_MAP.get(lang_key, "de") payload = { "model_id": self.model, "transcript": text.strip(), "voice": {"mode": "id", "id": voice_id}, "output_format": { "container": "raw", "encoding": "pcm_s16le", "sample_rate": self.sample_rate, }, "language": lang_code, } timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0) async with httpx.AsyncClient(timeout=timeout) as client: try: response = await client.post( _API_URL, headers={ "X-API-Key": self.api_key, "Cartesia-Version": _API_VERSION, "Content-Type": "application/json", }, json=payload, ) response.raise_for_status() except httpx.HTTPStatusError as exc: raise RuntimeError( f"Cartesia TTS error {exc.response.status_code}: {exc.response.text}" ) from exc except httpx.TimeoutException as exc: raise RuntimeError("Cartesia TTS timeout") from exc except httpx.HTTPError as exc: raise RuntimeError(f"Cartesia TTS transport error: {exc}") from exc if not response.content: raise RuntimeError("Cartesia TTS returned empty audio") return response.content ``` **Wichtig:** Cartesia hat `sonic-2` und alle anderen älteren Modelle abgekündigt. `sonic-turbo` ist das einzige aktuell funktionierende Modell (Stand 2026-06). `sonic-turbo` unterstützt: DE, EN, FR, ES, ZH, PT, JA, KO, HI — aber **nicht** AR, IT, RU, PL, NL, SV, TR. --- ### 1.7 `app/dependencies.py` — Routing-Erweiterungen #### A) `voice_gender` zu `ROUTE_KEYS` hinzufügen ```python ROUTE_KEYS = ( "input_endpoint", "output_endpoint", "stt_provider", "llm_provider", "tts_provider", "language", "language_mode", "voice_gender", # neu ) ``` #### B) `LANG_TO_PIPER_VOICE` — `nl` entfernen, neue Sprachen ergänzen ```python LANG_TO_PIPER_VOICE: dict[str, str] = { "de": "de_DE-thorsten-high", "en": "en_US-lessac-high", "fr": "fr_FR-siwis-medium", "es": "es_ES-sharvard-medium", "it": "it_IT-paola-medium", "pt": "pt_BR-faber-medium", "pl": "pl_PL-darkman-medium", "ar": "ar_JO-kareem-medium", "ru": "ru_RU-irina-medium", "zh": "zh_CN-huayan-medium", "cmn": "zh_CN-huayan-medium", } ``` #### C) `PIPER_VOICE_GENDERED` neu anlegen (nach `LANG_TO_PIPER_VOICE`) ES sharvard ist ein Multi-Speaker-Modell (`speaker_id_map: {'M': 0, 'F': 1}`), deshalb `#0`/`#1`-Suffix statt separater Dateien. ZH, PT, AR haben keinen gleichwertigen Geschlechtspartner in der piper-voices-Bibliothek — dort greift `LANG_TO_PIPER_VOICE`. ```python # Geschlechtsspezifische Piper-Stimmen für Sprachen mit m+f Varianten. # Präzedenz: PIPER_VOICE_GENDERED > LANG_TO_PIPER_VOICE. # ES sharvard ist ein Multi-Speaker-Modell: #0=M, #1=F (speaker_id_map: {'M':0,'F':1}) PIPER_VOICE_GENDERED: dict[tuple[str, str], str] = { ("de", "f"): "de_DE-kerstin-low", ("de", "m"): "de_DE-thorsten-high", ("en", "f"): "en_US-amy-medium", ("en", "m"): "en_US-lessac-high", ("fr", "f"): "fr_FR-siwis-medium", ("fr", "m"): "fr_FR-tom-medium", ("es", "f"): "es_ES-sharvard-medium#1", ("es", "m"): "es_ES-sharvard-medium#0", ("it", "f"): "it_IT-paola-medium", ("it", "m"): "it_IT-riccardo-x_low", ("ru", "f"): "ru_RU-irina-medium", ("ru", "m"): "ru_RU-ruslan-medium", ("pl", "f"): "pl_PL-gosia-medium", ("pl", "m"): "pl_PL-darkman-medium", } ``` #### D) `piper_voice_for_language()` ersetzen durch `voice_for_route()` Die bisherige Funktion `piper_voice_for_language()` durch zwei Funktionen ersetzen: ```python def piper_voice_for_language(tts_provider: str, language: str | None) -> str | None: """Kompatibilitäts-Alias für voice_for_route ohne gender.""" return voice_for_route(tts_provider, language, "any") def voice_for_route( tts_provider: str, language: str | None, voice_gender: str = "any" ) -> str | None: """Liefert den voice-Parameter für synthesize() passend zum Provider. - piper: Piper-Stimmmodell-Name; geschlechtsspezifisch wenn Varianten vorhanden - cartesia: Geschlechts-Code ("m"/"f"/"any") → CartesiaTTSProvider löst UUID auf - andere: None (Provider nutzt seinen eigenen Default) """ if tts_provider == "piper" and language: lang = language.lower() gender = (voice_gender or "any").lower() if gender in ("m", "f"): candidates = [gender, "f" if gender == "m" else "m"] else: # "any" → weiblich bevorzugt (konsistent mit Cartesia) candidates = ["f", "m"] for g in candidates: voice = PIPER_VOICE_GENDERED.get((lang, g)) if voice: return voice return LANG_TO_PIPER_VOICE.get(lang) if tts_provider == "cartesia": return voice_gender or "any" return None ``` #### E) `voice_for_route` in `api/ws.py`, `api/chat.py`, `api/speak.py` einbinden In allen drei Dateien: ```python from app.dependencies import ( piper_voice_for_language, voice_for_route, # neu importieren ... ) ``` Alle Aufrufe von `piper_voice_for_language(route.tts_provider, route.language)` ersetzen durch: ```python voice_for_route(route.tts_provider, route.language, route.voice_gender) ``` #### F) `ResolvedRoute` — `voice_gender` ergänzen ```python @dataclass class ResolvedRoute: input_endpoint: str output_endpoint: str stt_provider: str llm_provider: str tts_provider: str language: str language_mode: str = "fix" voice_gender: str = "any" # neu ``` Und in `as_dict()`: ```python "voice_gender": self.voice_gender, ``` #### G) CartesiaTTSProvider-Konstruktor — `voices_str` übergeben In `TTS_REGISTRY`: ```python "cartesia": lambda s: CartesiaTTSProvider( s.cartesia_api_key, s.cartesia_voice_id, s.cartesia_tts_model, s.tts_sample_rate, voices_str=s.cartesia_voices, ), ``` --- ### 1.8 `app/config.py` — Cartesia-Felder ergänzen In der `Settings`-Klasse: ```python cartesia_voice_id: str = "" # UUID aus cartesia.ai/voices (Fallback) cartesia_tts_model: str = "sonic-turbo" cartesia_voices: str = "" # "lang:gender:uuid:name" je Zeile, # = Kommentar ``` --- ## Teil 2: Deployment auf dem Server ### 2.1 System-Benutzer und Verzeichnisse ```bash sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid voice voice sudo mkdir -p /var/lib/voice-assistant/hf-cache sudo mkdir -p /etc/voice-assistant ``` ### 2.2 Repository klonen ```bash sudo git clone https://kitux.de/forgejo/dschlueter/my_voice_assistant_v3 /opt/voice-assistant sudo chown -R voice:voice /opt/voice-assistant /var/lib/voice-assistant ``` ### 2.3 Python-Umgebung ```bash cd /opt/voice-assistant sudo -u voice python3 -m venv .venv sudo -H -u voice .venv/bin/pip install -e ".[local]" sudo -H -u voice .venv/bin/pip install faster-whisper piper-tts ``` ### 2.4 Piper-Stimmen herunterladen (10 Sprachen, 16 Dateien) | Dateiname | Sprache | Geschlecht | Qualität | |---|---|---|---| | `de_DE-thorsten-high` | Deutsch | ♂ | high | | `de_DE-kerstin-low` | Deutsch | ♀ | low | | `en_US-lessac-high` | Englisch | ♂ | high | | `en_US-amy-medium` | Englisch | ♀ | medium | | `fr_FR-siwis-medium` | Französisch | ♀ | medium | | `fr_FR-tom-medium` | Französisch | ♂ | medium | | `es_ES-sharvard-medium` | Spanisch | ♂+♀ (Multi-Speaker: #0=M, #1=F) | medium | | `it_IT-paola-medium` | Italienisch | ♀ | medium | | `it_IT-riccardo-x_low` | Italienisch | ♂ | x_low | | `pt_BR-faber-medium` | Portugiesisch (BR) | ♂ (kein ♀ verfügbar) | medium | | `pl_PL-gosia-medium` | Polnisch | ♀ | medium | | `pl_PL-darkman-medium` | Polnisch | ♂ | medium | | `ar_JO-kareem-medium` | Arabisch (JO) | ♂ (kein ♀ verfügbar) | medium | | `ru_RU-irina-medium` | Russisch | ♀ | medium | | `ru_RU-ruslan-medium` | Russisch | ♂ | medium | | `zh_CN-huayan-medium` | Chinesisch | ♀ (kein ♂ verfügbar) | medium | Das Skript `scripts/download_voices.py` übernimmt den Download automatisch: ```bash cd /opt/voice-assistant sudo -u voice python scripts/download_voices.py ``` Es prüft, welche Dateien bereits vorhanden sind, und lädt nur fehlende nach. Mit `--force` werden alle Stimmen neu heruntergeladen. Manuell (Fallback ohne Python): ```bash BASE="https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0" DIR="/opt/voice-assistant/config/voices" declare -A VOICES=( ["de_DE-thorsten-high"]="de/de_DE/thorsten/high" ["de_DE-kerstin-low"]="de/de_DE/kerstin/low" ["en_US-lessac-high"]="en/en_US/lessac/high" ["en_US-amy-medium"]="en/en_US/amy/medium" ["fr_FR-siwis-medium"]="fr/fr_FR/siwis/medium" ["fr_FR-tom-medium"]="fr/fr_FR/tom/medium" ["es_ES-sharvard-medium"]="es/es_ES/sharvard/medium" ["it_IT-paola-medium"]="it/it_IT/paola/medium" ["it_IT-riccardo-x_low"]="it/it_IT/riccardo/x_low" ["pt_BR-faber-medium"]="pt/pt_BR/faber/medium" ["pl_PL-gosia-medium"]="pl/pl_PL/gosia/medium" ["pl_PL-darkman-medium"]="pl/pl_PL/darkman/medium" ["ar_JO-kareem-medium"]="ar/ar_JO/kareem/medium" ["ru_RU-irina-medium"]="ru/ru_RU/irina/medium" ["ru_RU-ruslan-medium"]="ru/ru_RU/ruslan/medium" ["zh_CN-huayan-medium"]="zh/zh_CN/huayan/medium" ) for NAME in "${!VOICES[@]}"; do PATH_PART="${VOICES[$NAME]}" sudo -u voice wget -q "$BASE/$PATH_PART/$NAME.onnx" -O "$DIR/$NAME.onnx" & sudo -u voice wget -q "$BASE/$PATH_PART/$NAME.onnx.json" -O "$DIR/$NAME.onnx.json" & done wait ``` ### 2.5 Umgebungsvariablen `/etc/voice-assistant/voice-assistant.env` ```ini # Voice Assistant Gateway — jamulix.de HOST=127.0.0.1 PORT=8003 OPENROUTER_API_KEY= CARTESIA_API_KEY= VA_PROFILE=jamulix VA_CONFIG_FILE=/opt/voice-assistant/config/voice-assistant.toml AUTH_ENABLED=true ADMIN_API_KEY= TRUSTED_AUTH_HEADER=Remote-User TRUSTED_PROXY_IPS=127.0.0.1 ADMIN_USERS=dschlueter DB_PATH=/var/lib/voice-assistant/voice-assistant.db HF_HOME=/var/lib/voice-assistant/hf-cache SSO_LOGOUT_URL=https://auth.jamulix.de/?action=logout ``` ```bash sudo chmod 640 /etc/voice-assistant/voice-assistant.env sudo chown root:voice /etc/voice-assistant/voice-assistant.env ``` ### 2.6 TOML-Konfiguration `/opt/voice-assistant/config/voice-assistant.toml` ```toml # Konfiguration für jamulix.de # Profil: jamulix — STT lokal (faster-whisper), LLM OpenRouter, TTS Piper/Cartesia [defaults] default_language = "de" default_input_endpoint = "local-default" default_output_endpoint = "local-default" openrouter_llm_model = "mistralai/mistral-small-24b-instruct-2501" llm_fallback = "openrouter" # WICHTIG: Piper als TTS-Fallback. Greift, wenn Cartesia eine Sprache nicht kann # (IT/RU/AR/PL). Ohne diese Zeile schlägt TTS für diese Sprachen still fehl, # statt auf die lokale Piper-Stimme auszuweichen. tts_fallback = "piper" openrouter_tts_model = "openai/gpt-audio-mini" openrouter_tts_voice = "alloy" openrouter_stt_model = "openai/whisper-large-v3" piper_voices_dir = "/opt/voice-assistant/config/voices" piper_voice = "de_DE-thorsten-high" # Cartesia Stimmen: Format lang:gender:uuid:name (# = Kommentar / Alternative) # Modell: sonic-turbo (einziges aktuell verfügbares Modell, Stand 2026-06) # Unterstützte Sprachen: DE, EN, FR, ES, ZH, PT — NICHT IT, RU, AR, PL cartesia_voices = """ de:m:b7187e84-fe22-4344-ba4a-bc013fcb533e:Sebastian de:f:38aabb6a-f52b-4fb0-a3d1-988518f4dc06:Alina en:m:4f7f1324-1853-48a6-b294-4e78e8036a83:Caspar en:f:62ae83ad-4f6a-430b-af41-a9bede9286ca:Gemma fr:m:0418348a-0ca2-4e90-9986-800fb8b3bbc0:Antoine fr:f:7c58f4a4-a72c-42fa-a503-41b9408820f3:Amélie es:m:2695b6b5-5543-4be1-96d9-3967fb5e7fec:Augustin (mx) # es:m:13ff5deb-2591-42ad-a356-63a04e524411:Marcos (cast) es:f:5c5ad5e7-1020-476b-8b91-fdcbe9cc313c:Daniela (mx) # es:f:9d8c6b2e-0a23-4a15-ae1b-121d5b5af417:Nuria (cast) pt:m:b603811e-54c2-4a0a-8854-09eab9ffa63f:Bruno pt:f:c9611be8-aae9-4a93-bb1c-98dd6b7d52a4:Isabella # it:m:408daed0-c597-4c27-aae8-fa0497d644bf:Matteo — sonic-turbo unterstützt IT nicht # it:f:d718e944-b313-4998-b011-d1cc078d4ef3:Liv — sonic-turbo unterstützt IT nicht # ar:m:40f9b5d1-bc79-43a6-b5cc-1c692b3b40d2:Zain — Cartesia unterstützt AR nicht # ar:f:731ace69-ee17-41bc-8c6f-665c9f1db95c:Fatima — Cartesia unterstützt AR nicht # ru:m:1e4176b1-3db9-44d6-a601-4fe68b041942:Sergei — sonic-turbo unterstützt RU nicht # ru:f:064b17af-d36b-4bfb-b003-be07dba1b649:Tatiana — sonic-turbo unterstützt RU nicht # pl:m — sonic-turbo unterstützt PL nicht (Piper: pl_PL-darkman-medium) # pl:f — sonic-turbo unterstützt PL nicht (Piper: pl_PL-gosia-medium) zh:m:eda5bbff-1ff1-4886-8ef1-4e69a77640a0:Kai zh:f:7a5d4663-88ae-47b7-808e-8f9b9ee4127b:Hua """ [profiles.jamulix] default_stt_provider = "faster-whisper" default_llm_provider = "openrouter" default_tts_provider = "piper" [profiles.cloud] default_stt_provider = "openrouter" default_llm_provider = "openrouter" default_tts_provider = "openrouter" ``` ```bash sudo chown voice:voice /opt/voice-assistant/config/voice-assistant.toml ``` ### 2.7 systemd-Service installieren Die Datei `deploy/voice-assistant.service` aus dem Repository verwenden: ```bash sudo cp /opt/voice-assistant/deploy/voice-assistant.service \ /etc/systemd/system/voice-assistant.service sudo systemctl daemon-reload sudo systemctl enable --now voice-assistant ``` ### 2.8 nginx-Konfiguration `/etc/nginx/sites-available/voice.jamulix.de` **Wichtig:** `X-Forwarded-For` muss leer gesetzt werden (`""`), da uvicorn sonst die echte Browser-IP als Client-Host interpretiert und der `TRUSTED_PROXY_IPS=127.0.0.1`-Check fehlschlägt. ```nginx server { listen 80; listen [::]:80; server_name voice.jamulix.de; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name voice.jamulix.de; server_tokens off; ssl_certificate /etc/letsencrypt/live/voice.jamulix.de/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/voice.jamulix.de/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header X-XSS-Protection "1; mode=block" always; # Authelia Auth-Check (intern) location = /authelia-check { internal; proxy_pass http://127.0.0.1:9091/api/verify; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Uri $request_uri; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Method $request_method; } # Health-Check (öffentlich) location = /health { proxy_pass http://127.0.0.1:8003/health; } # WebSocket-Routen /ws/chat und /ws/voice location /ws/ { auth_request /authelia-check; auth_request_set $user $upstream_http_remote_user; error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$http_host$request_uri; proxy_pass http://127.0.0.1:8003; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Forwarded-For ""; proxy_set_header Remote-User $user; proxy_read_timeout 300s; proxy_send_timeout 300s; } # Alle anderen Routen — Authelia 2FA-Schutz location / { auth_request /authelia-check; auth_request_set $user $upstream_http_remote_user; error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$host$request_uri; proxy_pass http://127.0.0.1:8003; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For ""; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Remote-User $user; } } ``` ```bash sudo ln -sf /etc/nginx/sites-available/voice.jamulix.de \ /etc/nginx/sites-enabled/voice.jamulix.de sudo certbot --nginx -d voice.jamulix.de --non-interactive --agree-tos \ -m dieter.schlueter@linix.de sudo systemctl reload nginx ``` --- ## Teil 3: Bekannte Fallstricke | Problem | Ursache | Fix | |---|---|---| | `PermissionError: /home/voice` | Kein Home-Verzeichnis für `voice`-User | `HF_HOME=/var/lib/voice-assistant/hf-cache` in env setzen | | `{"detail":"Authentication required"}` | uvicorn liest `X-Forwarded-For` als Client-IP | `proxy_set_header X-Forwarded-For ""` in nginx | | WebSocket 404 | nginx hatte keine `/ws/`-Location mit Upgrade-Headern | Separate `location /ws/` mit `Upgrade`/`Connection`-Headern | | Piper findet Stimme nicht | `piper_voices_dir` zeigt auf falsches Verzeichnis | In TOML auf `/opt/voice-assistant/config/voices` setzen | | OpenRouter Rate-Limit 429 | Free-Tier-Modelle sind gemeinsam genutzt | Bezahlmodell verwenden (`mistral-small-24b`) | | Authelia `/api/authz/forward-auth` gibt 400 | Falscher Endpoint-Typ für nginx auth_request | `/api/verify` mit `X-Original-URL`-Header verwenden | | Cartesia `Model sunsetted` | `sonic-2` und alle Datums-Varianten sind abgekündigt | Modell auf `sonic-turbo` setzen (in `config.py` und TOML) | | Cartesia 400 bei IT/RU/AR/PL | `sonic-turbo` unterstützt diese Sprachen nicht | Für diese Sprachen Piper verwenden; Cartesia gibt klare Fehlermeldung | | Gender-Button: weiße Schrift auf weißem Grund (Tag-Modus) | Tailwind JIT generiert `text-slate-700` nur, wenn die Klasse im HTML-Quelltext vorkommt — per JS dynamisch hinzugefügte Klassen werden nicht gescannt | `text-slate-700` in die Button-Basis-Klasse im HTML eintragen; `applyGenderChoice()` setzt sie explizit für den inaktiven Zustand | | `PiperVoice.synthesize()` kennt kein `speaker_id`-Argument | Die piper-Python-Library erwartet `speaker_id` in `SynthesisConfig`, nicht als direkten Parameter | `from piper.config import SynthesisConfig; voice.synthesize(text, syn_config=SynthesisConfig(speaker_id=N))` | --- ## Teil 4: Verifikation > Zuletzt vollständig getestet: 2026-06-25 — pytest 213 passed ✓, Piper alle 10 Sprachen ✓, Cartesia DE/EN/FR/ES/PT/ZH ✓ ### 4.0 Automatische Test-Suite (PFLICHT vor jedem Deploy) Die pytest-Suite läuft offline und deterministisch (keine echten Netz-/API-Aufrufe, keine Stimm-Modelle nötig). Vor jedem Deploy ausführen — muss **grün** sein: ```bash cd /opt/voice-assistant sudo -u voice .venv/bin/pip install -q -e '.[test]' # einmalig: pytest installieren sudo -u voice .venv/bin/python -m pytest -q # Erwartet: "213 passed" ``` Abdeckung u. a.: Stimm-/Geschlechts-Routing (`voice_for_route`), Piper Multi-Speaker (`#speaker_id`) und Gender-Code-Auflösung, Cartesia Voice-Map + nicht unterstützte Sprachen, Cartesia→Piper-Fallback. Die Fixture in `tests/conftest.py` neutralisiert die Deployment-Fallback-Ketten (`*_fallback`), damit Tests hermetisch bleiben. ### 4.1 Manuelle Smoke-Checks ```bash # Service läuft? sudo systemctl status voice-assistant # Health-Check curl -s https://voice.jamulix.de/health # Capability-Flags korrekt? curl -s http://127.0.0.1:8003/api/config | python3 -c " import json,sys; d=json.load(sys.stdin) print('chatterbox_reachable: ', d['available']['chatterbox_reachable']) print('openrouter_tts_available:', d['available']['openrouter_tts_available']) " # WebSocket erreichbar? curl -s --no-buffer \ -H "Upgrade: websocket" -H "Connection: Upgrade" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ -H "Sec-WebSocket-Version: 13" -H "Remote-User: dschlueter" \ "http://127.0.0.1:8003/ws/voice?session_id=test" | head -1 # Erwartet: HTTP/1.1 101 Switching Protocols # Piper — alle Stimmen testen (DE/EN/FR/ES/IT/RU/PL je ♂+♀; AR/ZH/PT nur eine Stimme) cd /opt/voice-assistant && sudo -u voice .venv/bin/python - << 'EOF' import asyncio from app.providers.tts.piper import PiperTTSProvider from app.dependencies import voice_for_route from app.config import settings as cfg p = PiperTTSProvider(cfg.piper_bin, cfg.piper_voices_dir, cfg.piper_voice, cfg.tts_sample_rate) async def run(): for lang, gender, text in [ ("de","f","Guten Tag!"), ("de","m","Guten Tag!"), ("en","f","Hello!"), ("en","m","Hello!"), ("fr","f","Bonjour!"), ("fr","m","Bonjour!"), ("es","f","¡Hola!"), ("es","m","¡Hola!"), ("it","f","Ciao!"), ("it","m","Ciao!"), ("ru","f","Привет!"), ("ru","m","Привет!"), ("pl","f","Cześć!"), ("pl","m","Cześć!"), ("ar","f","مرحباً!"), ("ar","m","مرحباً!"), # → immer kareem (kein ♀) ("pt","f","Bom dia!"), ("pt","m","Bom dia!"), # → immer faber (kein ♀) ("zh","f","你好!"), ("zh","m","你好!"), # → immer huayan (kein ♂) ]: voice = voice_for_route("piper", lang, gender) audio = await p.synthesize(text, voice=voice, audio_format="pcm") print(f"{'✓' if len(audio)>5000 else '✗'} {lang} {gender} {voice}") asyncio.run(run()) EOF # Cartesia — alle unterstützten Sprachen je ♂+♀ testen # Hinweis: env-Datei muss explizit geladen werden (wird sonst nur vom systemd-Service eingelesen) cd /opt/voice-assistant && sudo -u voice env $(sudo cat /etc/voice-assistant/voice-assistant.env | grep -v '^#' | xargs) .venv/bin/python - << 'EOF' import asyncio from app.providers.tts.cartesia import CartesiaTTSProvider from app.config import settings as cfg p = CartesiaTTSProvider(cfg.cartesia_api_key, cfg.cartesia_voice_id, cfg.cartesia_tts_model, cfg.tts_sample_rate, voices_str=cfg.cartesia_voices) async def run(): for lang, gender, text in [ ("de","f","Guten Tag!"), ("de","m","Guten Tag!"), ("en","f","Hello!"), ("en","m","Hello!"), ("fr","f","Bonjour!"), ("fr","m","Bonjour!"), ("es","f","¡Hola!"), ("es","m","¡Hola!"), ("pt","f","Bom dia!"), ("pt","m","Bom dia!"), ("zh","f","你好!"), ("zh","m","你好!"), ]: vid = p._resolve_voice_id(lang, gender) name = next((v["name"] for (l,g),v in p.voice_map.items() if v["uuid"]==vid), "?") audio = await p.synthesize(text, voice=gender, language=lang) print(f"{'✓' if len(audio)>5000 else '✗'} {lang} {gender} {name}") asyncio.run(run()) EOF ``` --- ## Teil 5: Authelia — E-Mail-Versand einrichten Authelia schreibt Benachrichtigungen (TOTP-Registrierung, Passwort-Reset) standardmäßig in eine Log-Datei. Für echten E-Mail-Versand über `linix.de` als SMTP-Relay: ### 5.1 SMTP-Passwort in `/etc/authelia/secrets.env` eintragen ```bash echo 'AUTHELIA_NOTIFIER_SMTP_PASSWORD=' \ | sudo tee -a /etc/authelia/secrets.env > /dev/null ``` Die Datei wird bereits vom systemd-Service als `EnvironmentFile` geladen — Authelia erkennt `AUTHELIA_NOTIFIER_SMTP_PASSWORD` automatisch. ### 5.2 Notifier in `/etc/authelia/configuration.yml` umstellen Den Block `notifier:` ersetzen (war `filesystem`, jetzt `smtp`): ```yaml notifier: smtp: address: 'submission://linix.de:587' username: valinix@linix.de sender: "Voice Assistant " tls: skip_verify: false ``` ```bash sudo systemctl restart authelia ``` **Hinweis:** Port 587 mit STARTTLS (TLS 1.3). Port 465 ist auf linix.de nicht offen. --- ## Teil 6: Authelia — Benutzerverwaltung ### 6.1 Neuen User anlegen **Schritt 1 — Argon2id-Hash erzeugen:** ```bash sudo authelia crypto hash generate argon2 --password 'GewuenschtePasswort' # Ausgabe: Digest: $argon2id$v=19$... ``` **Schritt 2 — Eintrag in `/etc/authelia/users_database.yml` hinzufügen:** ```yaml username: password: $argon2id$v=19$...... displayname: Vorname Nachname email: name@example.com groups: [] disabled: false ``` Normale User brauchen keine Gruppe (`groups: []`). Nur `admins` hat Sonderbedeutung. **Schritt 3:** ```bash sudo systemctl restart authelia ``` Der neue User erhält beim ersten Login den TOTP-Einrichtungslink per Mail. ### 6.2 Username nachträglich umbenennen Alle drei Stellen müssen konsistent geändert werden: ```bash # 1. YAML sudo sed -i 's/^ altername:$/ neuername:/' /etc/authelia/users_database.yml # 2. SQLite — alle relevanten Tabellen for TABLE in totp_configurations authentication_logs user_opaque_identifier \ user_preferences identity_verification banned_user; do sudo sqlite3 /var/lib/authelia/db.sqlite3 \ "UPDATE $TABLE SET username='neuername' WHERE username='altername';" 2>/dev/null done # 3. Voice-Assistant-Env (falls ADMIN_USERS betroffen) sudo sed -i 's/altername/neuername/g' /etc/voice-assistant/voice-assistant.env sudo systemctl restart authelia voice-assistant ``` Das TOTP bleibt erhalten — kein Re-Enroll nötig. ### 6.3 TOTP eines Users zurücksetzen (Handy verloren o.ä.) ```bash sudo sqlite3 /var/lib/authelia/db.sqlite3 \ "DELETE FROM totp_configurations WHERE username='username';" sudo systemctl restart authelia ``` User erhält beim nächsten Login einen neuen Registrierungslink per Mail. --- ## Teil 7: Initiale Authelia-User auf jamulix.de | Username | Displayname | E-Mail | Gruppe | |---|---|---|---| | `dschlueter` | Dieter Schlüter | dieter.schlueter@linix.de | admins | | `valinix` | VA Linix | valinix@linix.de | — | --- ## Teil 8: Frontend-Fixes ### 8.1 Standard-Sprache DE statt Flex Neue User haben leere Einstellungen `{}` in der DB — das Frontend zeigt dann „Flex" als Fallback. Zwei Fixes: **A) JS-Fallback in `app/web/app.js` ändern** (Zeile ~286): ```javascript // Vorher: : (prefs.language || "flex"); // Nachher: : (prefs.language || "de"); ``` **B) Bestehende User ohne Einstellungen in der DB korrigieren:** ```bash sudo sqlite3 /var/lib/voice-assistant/voice-assistant.db \ "UPDATE users SET prefs_json='{\"language\": \"de\", \"language_mode\": \"fix\", \"tts_provider\": \"piper\"}' WHERE prefs_json='{}';" ``` --- ## Teil 9: OpenRouter LLM — Retry-Logik Free-Tier-Modelle auf OpenRouter sind oft rate-limited (429) oder temporär nicht verfügbar (404). Die Datei `app/providers/llm/openrouter.py` wurde mit einer Retry-Logik ausgestattet. ### 9.1 Vollständiger Ersatz von `app/providers/llm/openrouter.py` ```python import asyncio import time from collections.abc import AsyncIterator import httpx from app.providers.llm.base import ( LLMProvider, lang_instruction, with_lang_reminder, sse_delta, ) _RETRY_STATUS = {404, 429, 500, 502, 503} def _retry_wait(status: int, headers: httpx.Headers, attempt: int, body: str = "") -> float: """Wartezeit: JSON retry_after_seconds > Retry-After > X-RateLimit-Reset > exponentiell.""" if status == 429: if body: try: import json as _json d = _json.loads(body) secs = d.get("error", {}).get("metadata", {}).get("retry_after_seconds") if secs: return max(1.0, min(float(secs) + 0.5, 120.0)) except Exception: pass retry_after = headers.get("Retry-After") if retry_after: try: return max(1.0, min(float(retry_after) + 0.5, 120.0)) except ValueError: pass reset_ms = headers.get("X-RateLimit-Reset") if reset_ms: try: wait = (int(reset_ms) / 1000.0) - time.time() + 0.5 return max(1.0, min(wait, 120.0)) except ValueError: pass return 30.0 return 2.0 * attempt SYSTEM_PROMPT = """ You are a voice assistant for spoken conversations with older adults. Speak naturally, clearly, and calmly. Use short, simple sentences. Prefer plain everyday language over technical wording. Answer in the same language as the user, unless the user asks to switch languages. Important response rules: - Output plain text only. - No markdown. - No bullet points. - No numbered lists. - No tables. - No code. - No emojis. - No URLs unless the user explicitly asks for one. - Do not use asterisks, hashtags, or formatting symbols. - Do not write headings. - Do not use long disclaimers. Voice style rules: - Sound helpful, warm, and patient. - Keep answers brief by default: 1 to 3 short sentences. - If more detail is needed, explain step by step in natural spoken sentences. - Ask at most one follow-up question at a time. - If the answer contains several items, present them as natural speech, not as a list. - Use wording that sounds good when spoken aloud. - Avoid abbreviations when possible. - Avoid symbols when words are better. - Prefer complete spoken forms for dates, times, and numbers when useful. Safety and honesty rules: - If you are unsure, say so briefly and clearly. - Do not invent facts. - If current real-world information is needed and unavailable, say that clearly. Always optimize your answer for listening, not for reading. """.strip() class OpenRouterLLMProvider(LLMProvider): def __init__(self, api_key: str, model: str, max_retries: int = 5): self.api_key = (api_key or "").strip() self.model = (model or "").strip() self.max_retries = max(1, max_retries) def _build_messages( self, text: str, history: list[dict] | None, language: str | None = None ) -> list[dict]: if not self.api_key: raise ValueError("OPENROUTER_API_KEY is empty") if not self.model: raise ValueError("OPENROUTER_LLM_MODEL is empty") if not text or not text.strip(): raise ValueError("LLM input text is empty") system_content = SYSTEM_PROMPT instr = lang_instruction(language) if instr: system_content = f"{SYSTEM_PROMPT}\n\n{instr}" messages = [{"role": "system", "content": system_content}] if history: messages.extend(history) messages.append({"role": "user", "content": with_lang_reminder(text.strip(), language)}) return messages async def complete( self, text: str, history: list[dict] | None = None, session_id: str | None = None, language: str | None = None, ) -> str: payload = { "model": self.model, "messages": self._build_messages(text, history, language=language), } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) last_error: Exception | None = None for attempt in range(1, self.max_retries + 1): async with httpx.AsyncClient(timeout=timeout) as client: try: response = await client.post( "https://openrouter.ai/api/v1/chat/completions", headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", }, json=payload, ) if response.status_code in _RETRY_STATUS and attempt < self.max_retries: last_error = RuntimeError( f"OpenRouter LLM error {response.status_code}: {response.text}" ) await asyncio.sleep(_retry_wait(response.status_code, response.headers, attempt, response.text)) continue response.raise_for_status() except httpx.HTTPStatusError as exc: last_error = RuntimeError( f"OpenRouter LLM error {exc.response.status_code}: {exc.response.text}" ) if exc.response.status_code in _RETRY_STATUS and attempt < self.max_retries: await asyncio.sleep(_retry_wait(exc.response.status_code, exc.response.headers, attempt, exc.response.text)) continue raise last_error from exc except httpx.TimeoutException as exc: raise RuntimeError("OpenRouter LLM timeout") from exc except httpx.HTTPError as exc: raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc data = response.json() try: content = data["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as exc: raise RuntimeError(f"Unexpected OpenRouter LLM response: {data}") from exc if not content or not str(content).strip(): raise RuntimeError("OpenRouter LLM returned empty content") return str(content).strip() raise last_error or RuntimeError("OpenRouter LLM: all attempts failed") async def stream( self, text: str, history: list[dict] | None = None, session_id: str | None = None, language: str | None = None, ) -> AsyncIterator[str]: payload = { "model": self.model, "messages": self._build_messages(text, history, language=language), "stream": True, } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) last_error: Exception | None = None for attempt in range(1, self.max_retries + 1): async with httpx.AsyncClient(timeout=timeout) as client: try: async with client.stream( "POST", "https://openrouter.ai/api/v1/chat/completions", headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", }, json=payload, ) as response: if response.status_code in _RETRY_STATUS and attempt < self.max_retries: body = await response.aread() last_error = RuntimeError( f"OpenRouter LLM error {response.status_code}: " f"{body.decode(errors='replace')}" ) await asyncio.sleep(_retry_wait(response.status_code, response.headers, attempt, body.decode(errors='replace'))) continue if response.status_code >= 400: body = await response.aread() raise RuntimeError( f"OpenRouter LLM error {response.status_code}: " f"{body.decode(errors='replace')}" ) async for line in response.aiter_lines(): delta = sse_delta(line) if delta: yield delta return except httpx.TimeoutException as exc: raise RuntimeError("OpenRouter LLM timeout") from exc except httpx.HTTPError as exc: raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc raise last_error or RuntimeError("OpenRouter LLM: all attempts failed") ``` Priorität der Wartezeit bei 429: 1. `error.metadata.retry_after_seconds` im JSON-Body (zuverlässigster Wert) 2. HTTP-Header `Retry-After` (Sekunden) 3. HTTP-Header `X-RateLimit-Reset` (Millisekunden-Timestamp) 4. Fallback: 30 Sekunden `max_retries` ist im Konstruktor konfigurierbar (Standard: 5). ### 9.2 Empfohlenes LLM-Modell Für zuverlässigen Betrieb das **Bezahlmodell** verwenden: ```toml openrouter_llm_model = "mistralai/mistral-small-24b-instruct-2501" ``` Free-Tier-Modelle (`:free`-Suffix) sind für Produktion ungeeignet — hohe Wartezeiten durch geteilte Kapazität (z.B. Venice-Backend: 8 req/min).