diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index f1c9b4a..27aaefc 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -309,8 +309,11 @@ Verfügbare Stimmen je Modell (laut Anbieter-Doku — Preview, im Zweifel auspro `echo`, `fable`, `nova`, `onyx`, `sage`, `shimmer`, `verse`. > Es gibt keinen Endpoint, der TTS-Stimmen auflistet, und die Modelle sind Preview. -> **Authentischster Test:** Stimme setzen und probieren — ein ungültiger Name liefert -> einen OpenRouter-Fehler (HTTP 502 mit Klartext, der oft die gültigen Stimmen nennt). +> **Authentischster Test:** Stimme setzen und probieren — ein **wirklich** ungültiger Name +> liefert einen OpenRouter-Fehler (HTTP 502 mit Klartext, der oft die gültigen Stimmen nennt). +> Preview-Modelle antworten gelegentlich transient **leer** (HTTP 200, kein Audio) — das +> wiederholt der TTS-Provider automatisch (bis zu 3 Versuche), bevor ein Fehler kommt. Eine +> einzelne „empty audio content"-Meldung war also meist nur ein Aussetzer; einfach erneut versuchen. Umstellen: ```bash diff --git a/app/providers/tts/openrouter.py b/app/providers/tts/openrouter.py index 28186d1..d12fe9f 100644 --- a/app/providers/tts/openrouter.py +++ b/app/providers/tts/openrouter.py @@ -1,7 +1,14 @@ +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): @@ -36,27 +43,37 @@ class OpenRouterTTSProvider(TTSProvider): timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) async with httpx.AsyncClient(timeout=timeout) as client: - 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: - raise RuntimeError( - f"OpenRouter TTS error {exc.response.status_code}: {exc.response.text}" - ) from exc - except httpx.TimeoutException as exc: - raise RuntimeError("OpenRouter TTS timeout") from exc - except httpx.HTTPError as exc: - raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc + 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 not response.content: - raise RuntimeError("OpenRouter TTS returned empty audio content") + if attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(_RETRY_BACKOFF * (attempt + 1)) - return response.content + raise RuntimeError(last_error) diff --git a/tests/test_openrouter_tts.py b/tests/test_openrouter_tts.py new file mode 100644 index 0000000..62394bd --- /dev/null +++ b/tests/test_openrouter_tts.py @@ -0,0 +1,70 @@ +"""Tests fuer das Retry-Verhalten des OpenRouter-TTS (transiente leere/5xx-Antworten).""" + +import asyncio + +import httpx +import pytest + +import app.providers.tts.openrouter as orm +from app.providers.tts.openrouter import OpenRouterTTSProvider + +REQ = httpx.Request("POST", "https://openrouter.ai/api/v1/audio/speech") + + +def _run(coro): + return asyncio.run(coro) + + +def _patch(monkeypatch, responses): + """httpx-POST liefert nacheinander die vorgegebenen Antworten; kein echtes Sleep.""" + it = iter(responses) + calls = {"n": 0} + + async def fake_post(self, url, **kw): + calls["n"] += 1 + return next(it) + + async def no_sleep(*a, **k): + pass + + monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) + monkeypatch.setattr(orm.asyncio, "sleep", no_sleep) + return calls + + +def _provider(): + return OpenRouterTTSProvider("key", "some/model", "Zephyr") + + +def test_retries_on_empty_then_succeeds(monkeypatch): + calls = _patch(monkeypatch, [ + httpx.Response(200, content=b"", request=REQ), # transienter Aussetzer + httpx.Response(200, content=b"AUDIO", request=REQ), # dann echtes Audio + ]) + assert _run(_provider().synthesize("Hallo")) == b"AUDIO" + assert calls["n"] == 2 + + +def test_4xx_raises_immediately_without_retry(monkeypatch): + calls = _patch(monkeypatch, [ + httpx.Response(400, text="invalid voice", request=REQ), + ]) + with pytest.raises(RuntimeError, match="400"): + _run(_provider().synthesize("Hallo")) + assert calls["n"] == 1 # kein Retry bei 4xx + + +def test_all_empty_raises_after_attempts(monkeypatch): + calls = _patch(monkeypatch, [httpx.Response(200, content=b"", request=REQ)] * orm._MAX_ATTEMPTS) + with pytest.raises(RuntimeError, match="empty audio content"): + _run(_provider().synthesize("Hallo")) + assert calls["n"] == orm._MAX_ATTEMPTS + + +def test_5xx_then_success(monkeypatch): + calls = _patch(monkeypatch, [ + httpx.Response(503, text="upstream", request=REQ), + httpx.Response(200, content=b"OK", request=REQ), + ]) + assert _run(_provider().synthesize("Hallo")) == b"OK" + assert calls["n"] == 2