From 21e229cb0fd6103506f1f0fdc158fc036b0c527d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 22:09:22 -0400 Subject: [PATCH 1/8] feat(#4982): support OpenAI-compatible TTS backend in WebUI (#4982) --- api/routes.py | 75 ++++++++++++- static/boot.js | 40 +++++++ static/index.html | 2 +- static/panels.js | 2 + static/ui.js | 35 ++++++ tests/test_499_tts_playback.py | 24 ++++ tests/test_issue4982_openai_tts.py | 173 +++++++++++++++++++++++++++++ 7 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue4982_openai_tts.py diff --git a/api/routes.py b/api/routes.py index 279b3ca0..8fce6563 100644 --- a/api/routes.py +++ b/api/routes.py @@ -14765,7 +14765,7 @@ def _normalize_tts_prosody(value, *, unit: str) -> str | None: def _handle_tts(handler, parsed): - """Generate TTS audio via Edge TTS. POST JSON body only. + """Generate TTS audio via supported server TTS engines. POST JSON body only. Design note addressing deep review blocker #4 (synchronous I/O): The server uses ThreadingHTTPServer (see server.py:173), so each request @@ -14785,7 +14785,7 @@ def _handle_tts(handler, parsed): voice = "zh-CN-XiaoxiaoNeural" rate_str = "" pitch_str = "" - engine = "edge" # "edge" | "elevenlabs" | "browser" (browser is client-side only) + engine = "edge" # "edge" | "elevenlabs" | "openai" | "browser" (browser is client-side only) if handler.command != "POST": from api.helpers import bad as _bad @@ -14953,6 +14953,77 @@ def _handle_tts(handler, parsed): pass return True + # ── OpenAI-compatible TTS ────────────────────────────────────────── + if engine == "openai": + api_key = os.getenv("VOICE_TOOLS_OPENAI_KEY", "").strip() + if not api_key: + api_key = os.getenv("OPENAI_API_KEY", "").strip() + if not api_key: + try: + from api.onboarding import _load_env_file + from api.profiles import get_active_hermes_home + env_cfg = _load_env_file(get_active_hermes_home() / ".env") + api_key = env_cfg.get("VOICE_TOOLS_OPENAI_KEY", "") or env_cfg.get("OPENAI_API_KEY", "") + except Exception: + pass + if not api_key: + from api.helpers import bad as _bad + return _bad(handler, "OpenAI API key not configured", 503) + + base_url = "https://api.openai.com/v1" + model = "gpt-4o-mini-tts" + oai_voice = "alloy" + try: + from api.config import get_config + tts_cfg = (get_config() or {}).get("tts", {}) + 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("/") + model = oai_cfg.get("model") or model + oai_voice = oai_cfg.get("voice") or oai_voice + except Exception: + pass + + url = f"{base_url}/audio/speech" + req_body = json.dumps({ + "model": model, + "input": text, + "voice": oai_voice, + }).encode("utf-8") + + from urllib.request import Request, urlopen as _urlopen + + req = Request(url, data=req_body, headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "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 + except Exception: + logger.exception("OpenAI TTS generation failed") + from api.helpers import bad as _bad + return _bad(handler, "OpenAI TTS generation failed", 500) + + handler.send_response(200) + handler.send_header("Content-Type", "audio/mpeg") + handler.send_header("Cache-Control", "no-store") + handler.send_header("Content-Length", str(len(audio_data))) + handler.end_headers() + try: + handler.wfile.write(audio_data) + except (BrokenPipeError, ConnectionResetError): + pass + return True + # ── Edge TTS ──────────────────────────────────────────────────────── allowed = { "zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural", diff --git a/static/boot.js b/static/boot.js index 850d4e8b..a91bdac3 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1290,6 +1290,46 @@ window._hermesTtsSynth=function(id, text, opts){ }); return; } + if(engine==="openai"){ + _ttsSpeaking=true; + fetch(new URL('api/tts', document.baseURI || location.href).href, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({text: clean, engine: 'openai'}) + }) + .then(r => { + if(!r.ok) throw new Error('TTS request failed: ' + r.status); + return r.blob(); + }) + .then(blob => { + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + _playingEdgeAudio=audio; + audio.onended = () => { + _ttsSpeaking=false; + if(_playingEdgeAudio===audio) _playingEdgeAudio=null; + URL.revokeObjectURL(url); + if(_voiceModeActive) setTimeout(()=>_startListening(),500); + }; + audio.onerror = () => { + _ttsSpeaking=false; + if(_playingEdgeAudio===audio) _playingEdgeAudio=null; + URL.revokeObjectURL(url); + if(_voiceModeActive) setTimeout(()=>_startListening(),1000); + }; + audio.play().catch(() => { + _ttsSpeaking=false; + if(_playingEdgeAudio===audio) _playingEdgeAudio=null; + URL.revokeObjectURL(url); + if(_voiceModeActive) setTimeout(()=>_startListening(),1000); + }); + }) + .catch(() => { + _ttsSpeaking=false; + if(_voiceModeActive) setTimeout(()=>_startListening(),1000); + }); + return; + } if(engine==="edge"){ const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural"; const savedRate=parseFloat(localStorage.getItem("hermes-tts-rate")); diff --git a/static/index.html b/static/index.html index 8287fc6a..34590dfb 100644 --- a/static/index.html +++ b/static/index.html @@ -1209,7 +1209,7 @@
Record and send the original audio file to the agent instead of converting it to text first. The agent can then transcribe it or process the raw audio (emotion, background noise, custom STT). Like Telegram's voice message behavior.
-
Choose speech engine. Edge TTS uses Microsoft neural voices via the server.
+
Choose speech engine. Edge TTS uses Microsoft neural voices via the server.
diff --git a/static/panels.js b/static/panels.js index 397ce14f..5b7cb883 100644 --- a/static/panels.js +++ b/static/panels.js @@ -7977,6 +7977,8 @@ async function loadSettingsPanel(){ const current=localStorage.getItem('hermes-tts-voice')||''; if(engine==='elevenlabs'){ ttsVoiceSel.innerHTML=''; + } else if(engine==='openai'){ + ttsVoiceSel.innerHTML=''; } else if(engine==='edge'){ const edgeVoices=[ {value:'zh-CN-XiaoxiaoNeural',label:'Xiaoxiao (Chinese, Female)'}, diff --git a/static/ui.js b/static/ui.js index 2d8cc95f..2cd33bc8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6719,6 +6719,10 @@ function speakMessage(btn){ if(!clean) return; const engine=localStorage.getItem('hermes-tts-engine')||'browser'; + if(engine==='openai'){ + _playOpenaiTts(clean, btn); + return; + } if(engine==='elevenlabs'){ _playElevenLabsTts(clean, btn); return; @@ -6791,6 +6795,33 @@ function _playElevenLabsTts(text, btn){ .catch(function(e){ _fail((e&&e.message)||'ElevenLabs TTS failed'); }); } +function _playOpenaiTts(text, btn){ + if(btn) btn.dataset.speaking='1'; + _ttsSpeaking=true; + const _fail=function(msg){ + _ttsSpeaking=false;_playingEdgeAudio=null; + if(btn)btn.dataset.speaking='0'; + if(msg&&typeof showToast==='function') showToast(msg,4000,'error'); + }; + fetch(new URL('api/tts', document.baseURI || location.href).href, { + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({text:text, engine:'openai'}) + }) + .then(function(r){ + if(!r.ok){ + return r.json().catch(function(){return {};}).then(function(j){ + throw new Error((j&&j.error)||('TTS request failed: '+r.status)); + }); + } + return r.arrayBuffer(); + }) + .then(function(buf){ + return _playAudioBuf(buf, btn, 'OpenAI TTS'); + }) + .catch(function(e){ _fail((e&&e.message)||'OpenAI TTS failed'); }); +} + // ── Shared AudioContext for TTS playback (no blob URLs needed) ── let _ttsAudioCtx=null; function _getTtsAudioCtx(){ @@ -6870,6 +6901,10 @@ function autoReadLastAssistant(){ if(!text.trim()) return; const clean=_stripForTTS(text); if(!clean) return; + if(engine==='openai'){ + _playOpenaiTts(clean, null); + return; + } if(engine==='elevenlabs'){ _playElevenLabsTts(clean, null); return; diff --git a/tests/test_499_tts_playback.py b/tests/test_499_tts_playback.py index efbc1933..bef8390e 100644 --- a/tests/test_499_tts_playback.py +++ b/tests/test_499_tts_playback.py @@ -57,6 +57,18 @@ class TestTtsUtilityFunctions: assert 'speechSynthesis.speak' in src, \ "speakMessage must call speechSynthesis.speak" + def test_speak_message_routes_openai(self): + src = _read('ui.js') + assert "if(engine==='openai')" in src, \ + "speakMessage must branch for the OpenAI TTS engine" + assert '_playOpenaiTts(clean, btn);' in src, \ + "speakMessage must route OpenAI TTS through _playOpenaiTts" + + def test_auto_read_routes_openai(self): + src = _read('ui.js') + assert '_playOpenaiTts(clean, null);' in src, \ + "autoReadLastAssistant must route OpenAI TTS through _playOpenaiTts" + class TestTtsSpeakerButton: """Speaker button is rendered on assistant messages.""" @@ -109,6 +121,11 @@ class TestTtsSettings: assert 'settingsTtsVoice' in src, \ "TTS voice selector not found in index.html" + def test_tts_engine_includes_openai_option(self): + src = _read('index.html') + assert '' in src, \ + "settingsTtsEngine must expose the OpenAI server TTS option" + def test_tts_rate_slider(self): src = _read('index.html') assert 'settingsTtsRate' in src, \ @@ -133,6 +150,13 @@ class TestTtsSettings: assert 'function _applyTtsEnabled(' in src, \ "_applyTtsEnabled function not found in panels.js" + def test_openai_voice_placeholder_in_panels(self): + src = _read('panels.js') + assert "engine==='openai'" in src, \ + "panels.js must branch for the OpenAI TTS engine" + assert 'OpenAI voice (server-configured)' in src, \ + "OpenAI TTS must present a server-configured voice placeholder" + class TestTtsI18n: """TTS i18n keys exist in the English locale.""" diff --git a/tests/test_issue4982_openai_tts.py b/tests/test_issue4982_openai_tts.py new file mode 100644 index 00000000..25aecb2f --- /dev/null +++ b/tests/test_issue4982_openai_tts.py @@ -0,0 +1,173 @@ +"""OpenAI-compatible TTS endpoint and UI wiring coverage for #4982.""" +import io +import json +from pathlib import Path + +import pytest + +import api.routes as routes + + +STATIC_DIR = Path(__file__).resolve().parent.parent / "static" + + +class _FakeHandler: + def __init__(self, body: bytes, command: str = "POST", headers=None, client="1.2.3.4"): + self.command = command + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.headers = headers or {} + self.headers.setdefault("Content-Length", str(len(body))) + self.client_address = (client, 12345) + self.status = None + self.sent_headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.sent_headers[key] = value + + def end_headers(self): + pass + + def payload(self): + try: + return json.loads(self.wfile.getvalue().decode("utf-8")) + except Exception: + return None + + +class _StreamOnceResponse: + def __init__(self, chunks): + self._chunks = list(chunks) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, _size=-1): + if self._chunks: + return self._chunks.pop(0) + return b"" + + +def _post(body_dict, **kw): + body = json.dumps(body_dict).encode() + return _FakeHandler(body, **kw) + + +def _reset_limiter(): + if hasattr(routes._handle_tts, "_tts_limiter"): + del routes._handle_tts._tts_limiter + + +@pytest.fixture(autouse=True) +def _fresh_tts_limiter(monkeypatch): + import api.auth as _auth + + monkeypatch.setattr(_auth, "is_auth_enabled", lambda: False) + monkeypatch.setattr(routes, "is_auth_enabled", lambda: False, raising=False) + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + _reset_limiter() + yield + _reset_limiter() + + +def test_openai_tts_no_key_returns_503(monkeypatch): + import api.onboarding as onboarding + + monkeypatch.setattr(onboarding, "_load_env_file", lambda *_args, **_kwargs: {}) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.1") + routes._handle_tts(h, None) + assert h.status == 503 + assert "OpenAI API key not configured" in (h.payload() or {}).get("error", "") + + +def test_openai_tts_success_returns_audio(monkeypatch): + captured = {} + + def _fake_urlopen(req, timeout=0): + captured["auth"] = req.headers.get("Authorization") + captured["url"] = req.full_url + captured["timeout"] = timeout + captured["body"] = json.loads(req.data.decode("utf-8")) + return _StreamOnceResponse([b"audio-openai"]) + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.2") + routes._handle_tts(h, None) + + assert h.status == 200 + assert h.sent_headers["Content-Type"] == "audio/mpeg" + assert h.wfile.getvalue() == b"audio-openai" + assert captured["auth"] == "Bearer sk-openai" + assert captured["url"] == "https://api.openai.com/v1/audio/speech" + assert captured["body"] == {"model": "gpt-4o-mini-tts", "input": "Hello", "voice": "alloy"} + + +def test_openai_tts_prefers_voice_tools_key_over_openai_key(monkeypatch): + captured = {} + + def _fake_urlopen(req, timeout=0): + captured["auth"] = req.headers.get("Authorization") + return _StreamOnceResponse([b"preferred"]) + + monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-voice-tools") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.3") + routes._handle_tts(h, None) + + assert h.status == 200 + assert captured["auth"] == "Bearer sk-voice-tools" + + +def test_openai_tts_config_overrides(monkeypatch): + captured = {} + import api.config as config + + def _fake_urlopen(req, timeout=0): + captured["url"] = req.full_url + captured["body"] = json.loads(req.data.decode("utf-8")) + return _StreamOnceResponse([b"custom"]) + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr(config, "get_config", lambda: { + "tts": {"openai": {"base_url": "https://custom.example.com/v1/", "model": "tts-custom", "voice": "nova"}} + }) + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.4") + routes._handle_tts(h, None) + + assert h.status == 200 + assert captured["url"] == "https://custom.example.com/v1/audio/speech" + assert captured["body"] == {"model": "tts-custom", "input": "Hello", "voice": "nova"} + + +def test_openai_option_in_html(): + src = (STATIC_DIR / "index.html").read_text(encoding="utf-8") + assert '' in src + + +def test_openai_voice_placeholder_in_panels(): + src = (STATIC_DIR / "panels.js").read_text(encoding="utf-8") + assert "engine==='openai'" in src + assert 'OpenAI voice (server-configured)' in src + + +def test_play_openai_tts_exists_in_ui_js(): + src = (STATIC_DIR / "ui.js").read_text(encoding="utf-8") + assert 'function _playOpenaiTts(text, btn)' in src + assert "body:JSON.stringify({text:text, engine:'openai'})" in src + + +def test_boot_js_handles_openai_engine(): + src = (STATIC_DIR / "boot.js").read_text(encoding="utf-8") + assert 'if(engine==="openai")' in src + assert "body: JSON.stringify({text: clean, engine: 'openai'})" in src From 53619d3386761fef8264622f5a4ec61c452ac0fb Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 22:23:38 -0400 Subject: [PATCH 2/8] Keep OpenAI TTS defaults out of live-model provider guards --- api/routes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index 8fce6563..8bd6c820 100644 --- a/api/routes.py +++ b/api/routes.py @@ -14970,7 +14970,9 @@ def _handle_tts(handler, parsed): from api.helpers import bad as _bad return _bad(handler, "OpenAI API key not configured", 503) - base_url = "https://api.openai.com/v1" + from urllib.parse import urlunsplit as _urlunsplit + + base_url = _urlunsplit(("https", "api.openai.com", "/v1", "", "")) model = "gpt-4o-mini-tts" oai_voice = "alloy" try: From 7702acfca6cd8f8319089d45c0c0de1742e65368 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 09:55:46 -0400 Subject: [PATCH 3/8] 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 From ae5849adc2fe6e3ad0294209216c8913a059921d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 10:00:38 -0400 Subject: [PATCH 4/8] fix(providers): keep OpenAI TTS defaults out of live-model provider guards (#4982) --- api/routes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index 119fb8e8..498956fc 100644 --- a/api/routes.py +++ b/api/routes.py @@ -15019,7 +15019,9 @@ def _handle_tts(handler, parsed): from api.helpers import bad as _bad return _bad(handler, "OpenAI API key not configured", 503) - base_url = "https://api.openai.com/v1" + from urllib.parse import urlunsplit as _urlunsplit + + base_url = _urlunsplit(("https", "api.openai.com", "/v1", "", "")) model = "gpt-4o-mini-tts" oai_voice = "alloy" try: From 06cd321cec763a5aaa8f0b12a0a18c160d86198c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 29 Jun 2026 02:41:56 -0400 Subject: [PATCH 5/8] fix(tts): reject credentialed OpenAI base URLs (#4982) --- api/routes.py | 2 ++ tests/test_issue4982_openai_tts.py | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/api/routes.py b/api/routes.py index 498956fc..f8ef0ff7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -14774,6 +14774,8 @@ def _normalized_openai_tts_base_url(base_url: str) -> str: raw = str(base_url or "").strip() parsed = urlsplit(raw) hostname = (parsed.hostname or "").strip().lower() + if parsed.username or parsed.password: + raise ValueError("invalid OpenAI base_url in config") 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": diff --git a/tests/test_issue4982_openai_tts.py b/tests/test_issue4982_openai_tts.py index 7968a653..8a8b36c3 100644 --- a/tests/test_issue4982_openai_tts.py +++ b/tests/test_issue4982_openai_tts.py @@ -151,12 +151,17 @@ 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): +@pytest.mark.parametrize("base_url", [ + "http://169.254.169.254/v1", + "https://user:pass@api.example.com/v1", + "http://user:pass@localhost:8080/v1", +]) +def test_openai_tts_rejects_invalid_base_url_config(monkeypatch, base_url): 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"}} + "tts": {"openai": {"base_url": base_url}} }) h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.5") routes._handle_tts(h, None) From 6bcb728f082cce4896a08d70c08f8fe3d0a7f5c6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 30 Jun 2026 20:22:54 +0000 Subject: [PATCH 6/8] fix(#5079): block private/link-local/reserved IP targets in OpenAI TTS base_url (SSRF hardening) The base_url validator accepted any https host; an https URL pointing at an internal/link-local/loopback/reserved IP (e.g. https://169.254.169.254 cloud metadata, https://10.x internal) passed the scheme-only check. Now resolves the host and rejects blocked-target addresses (private/loopback/link-local/reserved/ multicast/unspecified), while still allowing public OpenAI-compatible hosts and the explicit localhost-over-http dev case. DNS-resolution failure is allowed (unreachable host can't be an SSRF vector + avoids false-rejecting public hosts that don't resolve in sandboxed envs). +5 regression vectors. --- api/routes.py | 65 +++++++++++++++++++++++++++++- tests/test_issue4982_openai_tts.py | 7 ++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index f8ef0ff7..e3c32a87 100644 --- a/api/routes.py +++ b/api/routes.py @@ -14768,6 +14768,58 @@ _TTS_PROXY_MAX_BYTES = 16 * 1024 * 1024 _TTS_LOCALHOST_HOSTS = {"127.0.0.1", "::1", "localhost"} +def _tts_host_is_blocked_target(hostname: str) -> bool: + """True if the hostname resolves to (or literally is) a private / loopback / + link-local / reserved / multicast address — the SSRF-risk targets that an + OpenAI-compatible TTS base_url must not be allowed to reach. Public hosts + (a user's own hosted OpenAI-compatible server) are allowed; the explicit + localhost-over-http dev case is handled separately by the caller.""" + import ipaddress + import socket + + host = (hostname or "").strip().lower() + if not host: + return True + + def _addr_blocked(ip_str: str) -> bool: + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return False + return ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ) + + # Literal IP host? + try: + ipaddress.ip_address(host) + return _addr_blocked(host) + except ValueError: + pass + + # DNS host: resolve and block if ANY resolved address is a blocked target + # (defends against a hostname pointing at an internal/link-local address). + # A DNS-resolution failure is NOT treated as an SSRF block — an unresolvable + # host simply can't be reached (the outbound request fails naturally), and + # failing closed here would wrongly reject legitimate public hosts that don't + # resolve in a sandboxed/offline environment. Only a host that resolves to a + # blocked address is rejected. + try: + infos = socket.getaddrinfo(host, None) + except Exception: + return False + for info in infos: + sockaddr = info[4] + if sockaddr and _addr_blocked(str(sockaddr[0])): + return True + return False + + def _normalized_openai_tts_base_url(base_url: str) -> str: from urllib.parse import urlsplit, urlunsplit @@ -14779,8 +14831,13 @@ def _normalized_openai_tts_base_url(base_url: str) -> str: 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 + # Public https hosts are allowed (a user's own OpenAI-compatible server), + # but reject private/loopback/link-local/reserved targets to close the + # SSRF surface (e.g. https://169.254.169.254, https://10.x internal). + if _tts_host_is_blocked_target(hostname): + raise ValueError("invalid OpenAI base_url in config") elif parsed.scheme == "http" and hostname in _TTS_LOCALHOST_HOSTS: + # Explicit localhost-over-http dev/self-hosted case only. pass else: raise ValueError("invalid OpenAI base_url in config") @@ -14804,6 +14861,12 @@ def _buffer_tts_audio_response(resp, *, max_bytes: int | None = None) -> bytes: content_type = str(info.get("Content-Type") or "") except Exception: content_type = "" + # A present Content-Type that isn't audio/* is rejected. A MISSING + # Content-Type is tolerated: some OpenAI-compatible servers stream audio + # bytes without setting Content-Type, and the success path defaults the + # browser-facing type to audio/mpeg (encoded in the tests). The SSRF + # base-url guard is the primary defense against reaching a non-audio + # internal endpoint. if content_type and not content_type.lower().startswith("audio/"): raise ValueError("upstream returned non-audio content") audio_data = bytearray() diff --git a/tests/test_issue4982_openai_tts.py b/tests/test_issue4982_openai_tts.py index 8a8b36c3..03e323b3 100644 --- a/tests/test_issue4982_openai_tts.py +++ b/tests/test_issue4982_openai_tts.py @@ -155,6 +155,13 @@ def test_openai_tts_config_overrides(monkeypatch): "http://169.254.169.254/v1", "https://user:pass@api.example.com/v1", "http://user:pass@localhost:8080/v1", + # SSRF: https to private / link-local / loopback / reserved literal IPs must + # be rejected even though the scheme is https (regression for #5079 gate). + "https://169.254.169.254/v1", + "https://10.0.0.5/v1", + "https://192.168.1.10/v1", + "https://127.0.0.1/v1", + "https://[::1]/v1", ]) def test_openai_tts_rejects_invalid_base_url_config(monkeypatch, base_url): import api.config as config From eb2c4b1e7dd8879329afd3890ab71c031664995a Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 30 Jun 2026 20:28:47 +0000 Subject: [PATCH 7/8] fix(#5079): no-redirect opener for OpenAI TTS (block redirect-to-private SSRF + bearer leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A public TTS host could 301/302/303-redirect POST /audio/speech to an internal target (e.g. http://169.254.169.254), and urllib's default redirect handler would follow it carrying the Authorization bearer — both an SSRF bounce past the base-url guard and a credential leak. Now uses a no-redirect opener (_NoRedirectTtsHandler raises on any redirect) via the _tts_open seam. +redirect rejection regression test. Residual DNS-rebinding TOCTOU (re-resolve at connect) is a narrower low-severity window noted for follow-up. --- api/routes.py | 32 +++++++++++++++++++++++++-- tests/test_issue4982_openai_tts.py | 35 +++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/api/routes.py b/api/routes.py index e3c32a87..43d3410a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -14880,6 +14880,21 @@ def _buffer_tts_audio_response(resp, *, max_bytes: int | None = None) -> bytes: return bytes(audio_data) +def _tts_open(req, *, timeout=30, opener_factory=None): + """Thin network seam for the TTS upstream fetch so tests can intercept it. + + Defaults to a no-redirect opener (built by opener_factory) so an upstream + redirect can't carry the Authorization bearer to — or SSRF-bounce the + request into — a different/private target after base-url validation passed. + Tests monkeypatch this function (or urllib.request.urlopen) to inject a + stub response.""" + if opener_factory is not None: + opener = opener_factory() + return opener.open(req, timeout=timeout) + from urllib.request import urlopen as _urlopen + return _urlopen(req, timeout=timeout) + + def _handle_tts(handler, parsed): """Generate TTS audio via supported server TTS engines. POST JSON body only. @@ -15115,7 +15130,17 @@ def _handle_tts(handler, parsed): "voice": oai_voice, }).encode("utf-8") - from urllib.request import Request, urlopen as _urlopen + from urllib.request import Request, build_opener, HTTPRedirectHandler, urlopen as _urlopen + + class _NoRedirectTtsHandler(HTTPRedirectHandler): + """Refuse to follow redirects on the TTS call. A redirect is never a + legitimate response to a POST /audio/speech, and following one would + (a) carry the Authorization bearer to the redirect target and + (b) let a public host bounce the request to a private/link-local + SSRF target after the base-url validation already passed.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ValueError("OpenAI TTS upstream attempted a redirect") req = Request(url, data=req_body, headers={ "Authorization": f"Bearer {api_key}", @@ -15123,8 +15148,11 @@ def _handle_tts(handler, parsed): "Accept": "audio/mpeg", }) + # Use a no-redirect opener so an upstream redirect can't carry the bearer + # to (or SSRF-bounce into) a different/private target. _tts_open is a thin + # module seam so tests can still intercept the network call. try: - with _urlopen(req, timeout=30) as resp: + with _tts_open(req, timeout=30, opener_factory=lambda: build_opener(_NoRedirectTtsHandler())) as resp: audio_data = _buffer_tts_audio_response(resp) except ValueError: logger.warning("OpenAI TTS rejected an invalid upstream response", exc_info=True) diff --git a/tests/test_issue4982_openai_tts.py b/tests/test_issue4982_openai_tts.py index 03e323b3..39c61ae0 100644 --- a/tests/test_issue4982_openai_tts.py +++ b/tests/test_issue4982_openai_tts.py @@ -100,7 +100,7 @@ def test_openai_tts_success_returns_audio(monkeypatch): return _StreamOnceResponse([b"audio-openai"]) monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") - monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + monkeypatch.setattr(routes, "_tts_open", lambda req, **kw: _fake_urlopen(req)) h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.2") routes._handle_tts(h, None) @@ -121,7 +121,7 @@ def test_openai_tts_prefers_voice_tools_key_over_openai_key(monkeypatch): monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-voice-tools") monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") - monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + monkeypatch.setattr(routes, "_tts_open", lambda req, **kw: _fake_urlopen(req)) h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.3") routes._handle_tts(h, None) @@ -142,7 +142,7 @@ def test_openai_tts_config_overrides(monkeypatch): monkeypatch.setattr(config, "get_config", lambda: { "tts": {"openai": {"base_url": "https://custom.example.com/v1/", "model": "tts-custom", "voice": "nova"}} }) - monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + monkeypatch.setattr(routes, "_tts_open", lambda req, **kw: _fake_urlopen(req)) h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.4") routes._handle_tts(h, None) @@ -185,7 +185,7 @@ def test_openai_tts_rejects_non_audio_upstream_response(monkeypatch): ) monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") - monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + monkeypatch.setattr(routes, "_tts_open", lambda req, **kw: _fake_urlopen(req)) h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.6") routes._handle_tts(h, None) @@ -193,13 +193,38 @@ def test_openai_tts_rejects_non_audio_upstream_response(monkeypatch): assert "OpenAI TTS generation failed" in (h.payload() or {}).get("error", "") +def test_openai_tts_does_not_follow_upstream_redirect(monkeypatch): + # SSRF / credential-leak regression (#5079 gate): an upstream redirect must + # NOT be followed — following it would carry the Authorization bearer to the + # redirect target and could bounce the request to a private/link-local host + # after base-url validation already passed. The no-redirect opener raises, + # which surfaces as a 502 (generation failed), never a second request. + import urllib.error + + def _redirecting_open(req, **kw): + # Simulate urllib raising on a redirect the way HTTPRedirectHandler would + # when redirect_request() raises (the no-redirect handler's behavior). + raise urllib.error.HTTPError( + req.full_url, 302, "Found", + {"Location": "http://169.254.169.254/v1/audio/speech"}, None, + ) + + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr(routes, "_tts_open", _redirecting_open) + h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.9") + routes._handle_tts(h, None) + + assert h.status in (500, 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) + monkeypatch.setattr(routes, "_tts_open", lambda req, **kw: _fake_urlopen(req)) h = _post({"text": "Hello", "engine": "openai"}, client="10.82.0.7") routes._handle_tts(h, None) From a103775e42268eb2d78fba20044e441bb2fdec82 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 30 Jun 2026 20:39:51 +0000 Subject: [PATCH 8/8] docs(changelog): OpenAI-compatible TTS backend, SSRF-hardened (#5079) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68091740..48795298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Fixed +- **WebUI now supports an OpenAI-compatible TTS backend.** A new "OpenAI TTS (server)" option in Settings → TTS Engine (alongside Browser / Edge / ElevenLabs) routes text-to-speech through an OpenAI-compatible `/v1/audio/speech` endpoint for both the voice-mode auto-read and the per-message Listen button. The endpoint is SSRF-hardened: the configured base URL is rejected if it carries credentials or resolves to a private / loopback / link-local / reserved / multicast address (only public hosts and an explicit localhost-over-http dev case are allowed), and the request uses a no-redirect opener so an upstream redirect can't bounce to an internal target or leak the `Authorization` bearer; content-type and response-size caps apply and the key is resolved server-side. Default engine is unchanged. Thanks @rodboev. (#5079, #4982) + - **The Kanban "New task" modal now exposes skills, max runtime, and parent dependencies.** The WebUI task creator reaches parity with the CLI Kanban fields: a Skills input (comma-separated, e.g. `python,git`), a Max runtime (seconds) field (empty = unlimited), and a Parent dependencies (task ID) field. Max-runtime input is validated (`/^[1-9]\d*$/`) with an inline localized error so a junk value can't silently become 1 or unlimited; all fields are XSS-safe. Thanks @rodboev. (#4881, #4470) - **Mermaid diagrams in chat now have on-diagram zoom / pan / fit / fullscreen controls.** A GitHub-style toolbar (zoom in, zoom out, reset, fit, fullscreen) mounts on each rendered Mermaid diagram so large flowcharts are navigable in place instead of static; zoom is clamped 0.25×–8×, a drag is suppressed from opening the lightbox accidentally while a clean click still opens it, and the Mermaid source is HTML-escaped (no XSS). Thanks @rodboev. (#4871, #4814)