my_voice_assistant_v3/app/providers/tts/openrouter.py
Dieter Schlüter 03892463a5 fix(tts): OpenRouter-TTS bei transient leerer/5xx-Antwort wiederholen
Preview-Modelle liefern gelegentlich HTTP 200 mit leerem Body (oder 5xx) -> bisher
brach das die ganze Sprech-Runde ab ('empty audio content', 502). Jetzt bis zu 3
Versuche mit linearem Backoff; 4xx (z. B. ungueltige Stimme) wird sofort gemeldet
(kein Retry). Damit klappt das Durchprobieren von Stimmen zuverlaessig (Live-Test:
alle 20 getesteten Gemini-Stimmen inkl. Leda liefern Audio).
Tests: tests/test_openrouter_tts.py (leer->ok, 4xx sofort, alles-leer, 5xx->ok).
Doku-Hinweis in BEDIENUNGSANLEITUNG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:06:47 +02:00

79 lines
3 KiB
Python

import asyncio
import httpx
from app.providers.tts.base import TTSProvider
# Preview-TTS-Modelle liefern gelegentlich HTTP 200 mit LEEREM Body oder ein
# transientes 5xx. Solche Aussetzer kurz wiederholen, statt die Runde abzubrechen.
_MAX_ATTEMPTS = 3
_RETRY_BACKOFF = 0.6 # Sekunden, linear ansteigend
class OpenRouterTTSProvider(TTSProvider):
def __init__(self, api_key: str, model: str, voice: str):
self.api_key = (api_key or "").strip()
self.model = (model or "").strip()
self.voice = (voice or "").strip()
async def synthesize(
self,
text: str,
voice: str | None = None,
audio_format: str = "pcm",
) -> bytes:
if not self.api_key:
raise ValueError("OPENROUTER_API_KEY is empty")
if not self.model:
raise ValueError("OPENROUTER_TTS_MODEL is empty")
if not text or not text.strip():
raise ValueError("TTS input text is empty")
effective_voice = (voice or self.voice).strip()
if not effective_voice:
raise ValueError("TTS voice is required for OpenRouter TTS")
payload = {
"model": self.model,
"input": text.strip(),
"voice": effective_voice,
"response_format": audio_format,
}
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
last_error = "OpenRouter TTS returned empty audio content"
for attempt in range(_MAX_ATTEMPTS):
try:
response = await client.post(
"https://openrouter.ai/api/v1/audio/speech",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
# 4xx (z. B. ungueltige Stimme) ist nicht transient -> sofort melden.
if status < 500:
raise RuntimeError(
f"OpenRouter TTS error {status}: {exc.response.text}"
) from exc
last_error = f"OpenRouter TTS error {status}: {exc.response.text}"
except httpx.TimeoutException:
last_error = "OpenRouter TTS timeout"
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc
else:
if response.content:
return response.content
# HTTP 200 mit leerem Body -> transienter Aussetzer, erneut versuchen.
if attempt < _MAX_ATTEMPTS - 1:
await asyncio.sleep(_RETRY_BACKOFF * (attempt + 1))
raise RuntimeError(last_error)