From 7702acfca6cd8f8319089d45c0c0de1742e65368 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 09:55:46 -0400 Subject: [PATCH] fix(tts): close rework blockers for OpenAI TTS (#4982) --- api/routes.py | 91 +++++++++++++++---- static/boot.js | 4 +- .../test_extension_tts_engine_registration.py | 10 +- tests/test_issue3510_elevenlabs_tts.py | 35 +++++++ tests/test_issue4982_openai_tts.py | 47 +++++++++- 5 files changed, 163 insertions(+), 24 deletions(-) diff --git a/api/routes.py b/api/routes.py index 8bd6c820..119fb8e8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -14764,6 +14764,57 @@ def _normalize_tts_prosody(value, *, unit: str) -> str | None: return None +_TTS_PROXY_MAX_BYTES = 16 * 1024 * 1024 +_TTS_LOCALHOST_HOSTS = {"127.0.0.1", "::1", "localhost"} + + +def _normalized_openai_tts_base_url(base_url: str) -> str: + from urllib.parse import urlsplit, urlunsplit + + raw = str(base_url or "").strip() + parsed = urlsplit(raw) + hostname = (parsed.hostname or "").strip().lower() + if not parsed.scheme or not parsed.netloc or parsed.query or parsed.fragment: + raise ValueError("invalid OpenAI base_url in config") + if parsed.scheme == "https": + pass + elif parsed.scheme == "http" and hostname in _TTS_LOCALHOST_HOSTS: + pass + else: + raise ValueError("invalid OpenAI base_url in config") + path = parsed.path.rstrip("/") or "" + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) + + +def _buffer_tts_audio_response(resp, *, max_bytes: int | None = None) -> bytes: + if max_bytes is None: + max_bytes = _TTS_PROXY_MAX_BYTES + headers = getattr(resp, "headers", None) + content_type = "" + if headers is not None: + try: + content_type = str(headers.get("Content-Type") or "") + except Exception: + content_type = "" + if not content_type: + try: + info = resp.info() + content_type = str(info.get("Content-Type") or "") + except Exception: + content_type = "" + if content_type and not content_type.lower().startswith("audio/"): + raise ValueError("upstream returned non-audio content") + audio_data = bytearray() + while True: + chunk = resp.read(65536) + if not chunk: + break + audio_data.extend(chunk) + if len(audio_data) > max_bytes: + raise ValueError("upstream audio exceeded byte limit") + return bytes(audio_data) + + def _handle_tts(handler, parsed): """Generate TTS audio via supported server TTS engines. POST JSON body only. @@ -14926,17 +14977,15 @@ def _handle_tts(handler, parsed): # Buffer the full response before sending first byte. # The streaming endpoint is designed for chunked delivery, but urllib's # chunked-read path adds per-chunk overhead that dominates short TTS - # payloads. With the 5000-char cap enforced above the buffer is bounded - # (a few MB of mp3 worst case), so full-buffer-then-send is faster in - # practice and simpler to reason about. - audio_data = b"" + # payloads. A hard cap keeps the buffered path bounded even if the + # upstream misbehaves. try: with _urlopen(req, timeout=30) as resp: - while True: - chunk = resp.read(65536) - if not chunk: - break - audio_data += chunk + audio_data = _buffer_tts_audio_response(resp) + except ValueError: + logger.warning("ElevenLabs TTS rejected an invalid upstream response", exc_info=True) + from api.helpers import bad as _bad + return _bad(handler, "ElevenLabs TTS generation failed", 502) except Exception: logger.exception("ElevenLabs TTS generation failed") from api.helpers import bad as _bad @@ -14970,9 +15019,7 @@ def _handle_tts(handler, parsed): from api.helpers import bad as _bad return _bad(handler, "OpenAI API key not configured", 503) - from urllib.parse import urlunsplit as _urlunsplit - - base_url = _urlunsplit(("https", "api.openai.com", "/v1", "", "")) + base_url = "https://api.openai.com/v1" model = "gpt-4o-mini-tts" oai_voice = "alloy" try: @@ -14981,9 +15028,16 @@ def _handle_tts(handler, parsed): if isinstance(tts_cfg, dict): oai_cfg = tts_cfg.get("openai", {}) if isinstance(oai_cfg, dict): - base_url = (oai_cfg.get("base_url") or base_url).rstrip("/") + base_url = _normalized_openai_tts_base_url(oai_cfg.get("base_url") or base_url) model = oai_cfg.get("model") or model oai_voice = oai_cfg.get("voice") or oai_voice + else: + base_url = _normalized_openai_tts_base_url(base_url) + else: + base_url = _normalized_openai_tts_base_url(base_url) + except ValueError: + from api.helpers import bad as _bad + return _bad(handler, "invalid OpenAI base_url in config", 400) except Exception: pass @@ -15002,14 +15056,13 @@ def _handle_tts(handler, parsed): "Accept": "audio/mpeg", }) - audio_data = b"" try: with _urlopen(req, timeout=30) as resp: - while True: - chunk = resp.read(65536) - if not chunk: - break - audio_data += chunk + audio_data = _buffer_tts_audio_response(resp) + except ValueError: + logger.warning("OpenAI TTS rejected an invalid upstream response", exc_info=True) + from api.helpers import bad as _bad + return _bad(handler, "OpenAI TTS generation failed", 502) except Exception: logger.exception("OpenAI TTS generation failed") from api.helpers import bad as _bad diff --git a/static/boot.js b/static/boot.js index a91bdac3..1cf50610 100644 --- a/static/boot.js +++ b/static/boot.js @@ -926,12 +926,12 @@ window._micPendingSend=window._micPendingSend||false; // handles selection, the dropdown option, and playback. Mirrors registerHermesSkin. // // window.registerHermesTtsEngine({ -// id: 'voicevox', // [a-z0-9_-], not a built-in (browser/edge/elevenlabs) +// id: 'voicevox', // [a-z0-9_-], not a built-in (browser/edge/elevenlabs/openai) // label: 'VOICEVOX (local)', // synthesize(text, opts) { return Promise; } // }) -> true on success, false if rejected var _HERMES_TTS_ENGINES = Object.create(null); -var _HERMES_TTS_RESERVED = { browser:1, edge:1, elevenlabs:1 }; +var _HERMES_TTS_RESERVED = { browser:1, edge:1, elevenlabs:1, openai:1 }; function _hermesTtsValidId(id){ return typeof id==='string' && /^[a-z0-9][a-z0-9_-]{0,31}$/.test(id); } function _hermesAddTtsOption(id, label){ var sel=document.getElementById('settingsTtsEngine'); diff --git a/tests/test_extension_tts_engine_registration.py b/tests/test_extension_tts_engine_registration.py index cfb616d1..ba59664d 100644 --- a/tests/test_extension_tts_engine_registration.py +++ b/tests/test_extension_tts_engine_registration.py @@ -31,8 +31,11 @@ def test_public_api_present(): def test_reserved_builtins_guarded(): assert "_HERMES_TTS_RESERVED" in BOOT_JS - # browser/edge/elevenlabs must be reserved so an extension can't shadow them - assert "browser:1" in BOOT_JS and "edge:1" in BOOT_JS and "elevenlabs:1" in BOOT_JS + # browser/edge/elevenlabs/openai must be reserved so an extension can't shadow them + assert "browser:1" in BOOT_JS + assert "edge:1" in BOOT_JS + assert "elevenlabs:1" in BOOT_JS + assert "openai:1" in BOOT_JS def test_both_playback_paths_check_registry(): @@ -75,6 +78,8 @@ def test_registration_behavior(): // reserved key rejected results.reservedRejected = (window.registerHermesTtsEngine({ id: 'edge', label: 'x', synthesize: () => new ArrayBuffer(1) }) === false); + results.openaiReservedRejected = (window.registerHermesTtsEngine({ + id: 'openai', label: 'x', synthesize: () => new ArrayBuffer(1) }) === false); // bad id rejected results.badIdRejected = (window.registerHermesTtsEngine({ id: 'Bad Id!', label: 'x', synthesize: () => new ArrayBuffer(1) }) === false); @@ -101,6 +106,7 @@ def test_registration_behavior(): assert r["validOk"] is True assert r["isRegistered"] is True assert r["reservedRejected"] is True + assert r["openaiReservedRejected"] is True assert r["badIdRejected"] is True assert r["noSynthRejected"] is True assert r["optionListed"] is True diff --git a/tests/test_issue3510_elevenlabs_tts.py b/tests/test_issue3510_elevenlabs_tts.py index 8517bae0..6ee67f2d 100644 --- a/tests/test_issue3510_elevenlabs_tts.py +++ b/tests/test_issue3510_elevenlabs_tts.py @@ -135,3 +135,38 @@ def test_elevenlabs_overlong_text_rejected_before_engine(monkeypatch): routes._handle_tts(h, None) assert h.status == 400 assert "too long" in (h.payload() or {}).get("error", "") + + +def test_elevenlabs_rejects_oversized_upstream_audio(monkeypatch): + monkeypatch.setenv("ELEVENLABS_API_KEY", "sk-test") + import api.config as _cfg + monkeypatch.setattr(_cfg, "get_config", lambda: { + "tts": {"elevenlabs": {"voice_id": "pNInz6obpgDQGcFmaJgB", "model": "eleven_multilingual_v2"}} + }) + + class _Resp: + def __init__(self): + self.headers = {"Content-Type": "audio/mpeg"} + self._chunks = [b"1234", b"5", b""] + self._i = 0 + def read(self, n=-1): + c = self._chunks[self._i] if self._i < len(self._chunks) else b"" + self._i += 1 + return c + def __enter__(self): + return self + def __exit__(self, *a): + return False + + def _fake_urlopen(req, timeout=30): + return _Resp() + + import urllib.request as _ur + monkeypatch.setattr(routes, "_TTS_PROXY_MAX_BYTES", 4) + monkeypatch.setattr(_ur, "urlopen", _fake_urlopen) + + h = _post({"text": "hello world", "engine": "elevenlabs"}, client="9.9.9.5") + routes._handle_tts(h, None) + + assert h.status == 502 + assert "ElevenLabs TTS generation failed" in (h.payload() or {}).get("error", "") diff --git a/tests/test_issue4982_openai_tts.py b/tests/test_issue4982_openai_tts.py index 25aecb2f..7968a653 100644 --- a/tests/test_issue4982_openai_tts.py +++ b/tests/test_issue4982_openai_tts.py @@ -39,8 +39,9 @@ class _FakeHandler: class _StreamOnceResponse: - def __init__(self, chunks): + def __init__(self, chunks, headers=None): self._chunks = list(chunks) + self.headers = headers or {} def __enter__(self): return self @@ -150,6 +151,50 @@ def test_openai_tts_config_overrides(monkeypatch): assert captured["body"] == {"model": "tts-custom", "input": "Hello", "voice": "nova"} +def test_openai_tts_rejects_invalid_base_url_config(monkeypatch): + import api.config as config + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr(config, "get_config", lambda: { + "tts": {"openai": {"base_url": "http://169.254.169.254/v1"}} + }) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.5") + routes._handle_tts(h, None) + + assert h.status == 400 + assert "base_url" in (h.payload() or {}).get("error", "") + + +def test_openai_tts_rejects_non_audio_upstream_response(monkeypatch): + def _fake_urlopen(_req, timeout=0): + return _StreamOnceResponse( + [b'{"error":"nope"}'], + headers={"Content-Type": "application/json"}, + ) + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.6") + routes._handle_tts(h, None) + + assert h.status == 502 + assert "OpenAI TTS generation failed" in (h.payload() or {}).get("error", "") + + +def test_openai_tts_rejects_oversized_upstream_audio(monkeypatch): + def _fake_urlopen(_req, timeout=0): + return _StreamOnceResponse([b"1234", b"5"], headers={"Content-Type": "audio/mpeg"}) + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr(routes, "_TTS_PROXY_MAX_BYTES", 4) + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.7") + routes._handle_tts(h, None) + + assert h.status == 502 + assert "OpenAI TTS generation failed" in (h.payload() or {}).get("error", "") + + def test_openai_option_in_html(): src = (STATIC_DIR / "index.html").read_text(encoding="utf-8") assert '' in src