fix(tts): close rework blockers for OpenAI TTS (#4982)

This commit is contained in:
Rod Boev committed 2026-06-28 09:55:46 -04:00
1 parent 53619d3386
commit 7702acfca6
5 files changed
+163 -24

No files matched your search

+72 -19
View File
@@ -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
+2 -2
View File
@@ -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<ArrayBuffer|Blob>; }
// }) -> 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');
@@ -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
+35
View File
@@ -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", "")
+46 -1
View File
@@ -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 '<option value="openai">OpenAI TTS (server)</option>' in src