fix(tts): close rework blockers for OpenAI TTS (#4982)
This commit is contained in:
1 parent
53619d3386
commit
7702acfca6
5 files changed
+163
-24
No files matched your search
+72
-19
@@ -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
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user