refactor(tools): compact tts local/openai/lifecycle/plugins modules, restore module-attr re-exports

This commit is contained in:
Teknium
2026-09-02 23:12:04 -07:00
parent aa97ee5ae2
commit 2c1e34724e
6 changed files with 166 additions and 247 deletions

View File

@@ -88,13 +88,18 @@ from tools.tts_tool_delivery import ( # noqa: F401 — historical names re-expo
_wrap_pcm_as_wav,
)
from tools.tts_tool_providers import ( # noqa: F401 — historical names re-exported
DEFAULT_ELEVENLABS_MODEL_ID,
DEFAULT_ELEVENLABS_VOICE_ID,
DEFAULT_GEMINI_TTS_MODEL,
DEFAULT_GEMINI_TTS_VOICE,
DEFAULT_MINIMAX_BASE_URL,
DEFAULT_MINIMAX_CN_BASE_URL,
TTS_RESPONSE_BODY_LIMIT_BYTES,
_XAI_FIRST_SENTENCE_RE,
_XAI_INLINE_SPEECH_TAGS,
_XAI_WRAPPING_SPEECH_TAGS,
_apply_xai_auto_speech_tags,
_elevenlabs_environment_kwargs,
_generate_edge_tts,
_generate_elevenlabs,
_generate_gemini_tts,
@@ -106,12 +111,14 @@ from tools.tts_tool_providers import ( # noqa: F401 — historical names re-exp
from tools.tts_tool_local import ( # noqa: F401 — historical names re-exported
DEFAULT_PIPER_VOICE,
_LOCAL_TTS_MODEL_CACHES,
_TTS_MODEL_CACHE_MAX,
_generate_kittentts,
_generate_neutts,
_generate_piper_tts,
_kittentts_model_cache,
_piper_voice_cache,
_resolve_piper_voice_path,
_tts_cache_get_or_load,
)
from tools.tts_tool_speaker import stream_tts_to_speaker # noqa: F401 — historical name re-exported
from tools.tts_text_normalize import _strip_markdown_for_tts # noqa: F401 — historical name re-exported
@@ -122,6 +129,9 @@ from tools.tts_tool_plugins import ( # noqa: F401 — historical names re-expor
)
from tools.tts_tool_openai import ( # noqa: F401 — historical names re-exported
DEFAULT_OPENAI_BASE_URL,
DEFAULT_OPENAI_MODEL,
DEFAULT_OPENAI_VOICE,
MANAGED_OPENAI_TTS_MODELS,
_generate_deepinfra_tts,
_generate_openai_tts,
_has_openai_audio_backend,

View File

@@ -1,18 +1,11 @@
"""Local-engine lifecycle for ``tools.tts_tool``: warm-up / release leases.
Local engines load their model lazily on first synthesis, so the first spoken
reply after a user turns speech output on pays the whole load as dead air,
and the model then stays resident forever. The toggles ARE the intent
signal: every surface that flips speech output on holds a *lease* here
(warming the configured engine); when the last lease is released the local
model caches are dropped. Lease-counting keeps one surface's "off" from
unloading a model another surface in this process still needs. Cloud
providers have nothing resident; warming them only ensures the lazily
installed SDK is importable.
Seams tests monkeypatch on the origin (``_load_tts_config``, ``_get_provider``,
``warm_tts_provider``, ``_run_command_tts``) are resolved through
:func:`_origin` at call time.
Local engines load their model lazily on first synthesis (dead air on the first spoken
reply) and then stay resident forever. Every surface that flips speech output on holds a
*lease* here (warming the configured engine); when the last lease is released the local
model caches are dropped, so one surface's "off" can't unload a model another surface
still needs. Cloud providers have nothing resident; warming only ensures the SDK imports.
Seams tests monkeypatch on the origin are resolved through :func:`_origin` at call time.
"""
from __future__ import annotations
@@ -31,9 +24,7 @@ from tools.tts_command_provider import (
render_command_template as _render_command_tts_template,
)
from tools.tts_tool_local import (
_LOCAL_TTS_MODEL_CACHES,
_load_kittentts_model_for_config,
_load_piper_voice_for_config,
_LOCAL_TTS_MODEL_CACHES, _load_kittentts_model_for_config, _load_piper_voice_for_config,
)
from tools.tts_tool_plugins import _lookup_plugin_provider
@@ -59,23 +50,18 @@ def _local_tts_warmers() -> Dict[str, Callable[[Dict[str, Any]], Any]]:
}
def _lazy_sdk_feature_for_provider(provider: str) -> Optional[str]:
"""tools.lazy_deps feature key for providers whose SDK installs on first use."""
return {
"edge": "tts.edge",
"elevenlabs": "tts.elevenlabs",
"mistral": "tts.mistral",
}.get(provider)
# tools.lazy_deps feature key for providers whose SDK installs on first use.
_LAZY_SDK_FEATURES = {"edge": "tts.edge", "elevenlabs": "tts.elevenlabs", "mistral": "tts.mistral"}
def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str) -> Optional[str]:
"""Forward a lease ``hook`` (``"warm"`` / ``"release"``) to a user-declared provider.
Command providers run their optional ``warm_command`` / ``release_command``
(same template/env/timeout rules as ``command``; output discarded) on a
background thread so a toggle never waits on a model server. Plugin
providers get :meth:`TTSProvider.warm` / :meth:`TTSProvider.release`.
Best-effort: failures are logged at debug. Returns the action taken.
Command providers run their optional ``warm_command`` / ``release_command`` (same
template/env/timeout rules as ``command``; output discarded) on a background thread so
a toggle never waits on a model server. Plugin providers get :meth:`TTSProvider.warm`
/ :meth:`TTSProvider.release`. Best-effort: failures are logged at debug. Returns the
action taken.
"""
if not name or name in BUILTIN_TTS_PROVIDERS:
return None
@@ -110,17 +96,13 @@ def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str)
return "error"
def warm_tts_provider(
tts_config: Optional[Dict[str, Any]] = None,
provider: Optional[str] = None,
) -> Dict[str, Any]:
def warm_tts_provider(tts_config: Optional[Dict[str, Any]] = None, provider: Optional[str] = None) -> Dict[str, Any]:
"""Pre-load the configured TTS provider so the next synthesis starts hot.
Local engines load their voice/model into the same LRU slot synthesis
reads (including first-use download); lazily-installed cloud SDKs are
made importable; user-declared providers get ``warm_command`` /
:meth:`TTSProvider.warm`; everything else is ``action: "noop"``.
Never raises — the result dict carries ``warmed`` / ``action`` /
Local engines load their voice/model into the same LRU slot synthesis reads (including
first-use download); lazily-installed cloud SDKs are made importable; user-declared
providers get ``warm_command`` / :meth:`TTSProvider.warm`; everything else is
``action: "noop"``. Never raises — the result dict carries ``warmed`` / ``action`` /
``error``. Blocking; UI threads should run it in the background.
"""
if tts_config is None:
@@ -150,10 +132,11 @@ def warm_tts_provider(
signalled = _signal_user_tts_provider(name, tts_config, "warm")
if signalled is not None:
result.update(warmed=signalled != "error", action="warmed" if signalled != "error" else "error")
ok = signalled != "error"
result.update(warmed=ok, action="warmed" if ok else "error")
return result
feature = _lazy_sdk_feature_for_provider(name)
feature = _LAZY_SDK_FEATURES.get(name)
if feature is not None:
try:
from tools.lazy_deps import ensure, is_available
@@ -172,10 +155,9 @@ def warm_tts_provider(
def release_tts_provider(provider: Optional[str] = None) -> Dict[str, Any]:
"""Drop resident local TTS models so their memory is returned.
With ``provider`` given only that engine's cache is cleared; otherwise
every local cache is, and the configured user-declared provider is
signalled (plugin ``release()`` / command ``release_command``). Returns
``{"released": <model instances dropped>}``.
With ``provider`` given only that engine's cache is cleared; otherwise every local
cache is, and the configured user-declared provider is signalled (plugin ``release()``
/ command ``release_command``). Returns ``{"released": <model instances dropped>}``.
"""
name = (provider or "").lower().strip()
if not name:
@@ -195,8 +177,8 @@ def release_tts_provider(provider: Optional[str] = None) -> Dict[str, Any]:
def acquire_tts_lease(lease: str, tts_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Register ``lease`` (e.g. ``"desktop:read-aloud"``) as a live consumer and warm the provider.
Re-acquiring is idempotent but still re-warms (cheap on a cache hit, and
heals a cache cleared elsewhere).
Re-acquiring is idempotent but still re-warms (cheap on a cache hit, and heals a cache
cleared elsewhere).
"""
with _tts_lease_lock:
_tts_leases.add(lease)
@@ -209,8 +191,8 @@ def acquire_tts_lease(lease: str, tts_config: Optional[Dict[str, Any]] = None) -
def release_tts_lease(lease: str) -> Dict[str, Any]:
"""Drop ``lease``; when it was the last one, unload resident local models.
Releasing a never-acquired lease is a no-op (still reports the holder
count) so surfaces can call it unconditionally on their "off" path.
Releasing a never-acquired lease is a no-op (still reports the holder count) so
surfaces can call it unconditionally on their "off" path.
"""
with _tts_lease_lock:
_tts_leases.discard(lease)

View File

@@ -1,11 +1,10 @@
"""Local on-device TTS engines for ``tools.tts_tool``: NeuTTS, Piper, KittenTTS.
All three synthesize WAV natively; :func:`_finalize_wav_output` (shared) then
converts/renames to the caller's requested container. Piper and KittenTTS keep
their loaded models in small LRU caches registered in
``_LOCAL_TTS_MODEL_CACHES`` so the origin module's warm/release lifecycle can
pre-load or drop them. ``_import_piper`` / ``_import_kittentts`` are resolved
through the origin module at call time so test monkeypatches there apply.
All three synthesize WAV natively; :func:`_finalize_wav_output` then converts/renames to
the caller's requested container. Piper and KittenTTS keep loaded models in small LRU
caches registered in ``_LOCAL_TTS_MODEL_CACHES`` so the warm/release lifecycle can
pre-load or drop them. ``_import_piper`` / ``_import_kittentts`` are resolved through the
origin module at call time so test monkeypatches there apply.
"""
from __future__ import annotations
@@ -23,6 +22,7 @@ logger = logging.getLogger("tools.tts_tool")
DEFAULT_KITTENTTS_MODEL = "KittenML/kitten-tts-nano-0.8-int8" # 25MB
DEFAULT_KITTENTTS_VOICE = "Jasper"
DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality
_NEUTTS_SAMPLES = Path(__file__).parent / "neutts_samples"
def _origin():
@@ -31,32 +31,28 @@ def _origin():
return tts_tool
# ===========================================================================
# ---------------------------------------------------------------------------
# Bounded model caches
# ===========================================================================
# Each cached entry is a whole loaded model (tens of MB); an unbounded dict
# would pin one per distinct voice for the process lifetime. Small LRU: most
# sessions use one or two voices and a cold reload is cheap.
# ---------------------------------------------------------------------------
# Each cached entry is a whole loaded model (tens of MB); an unbounded dict would pin one
# per distinct voice for the process lifetime. Most sessions use one or two voices and a
# cold reload is cheap.
_TTS_MODEL_CACHE_MAX = 3
# Provider name → the model cache it populates. Consulted by
# warm_tts_provider() / release_tts_provider() in the origin module; a new
# local engine adds one row here plus a loader in _local_tts_warmers().
_LOCAL_TTS_MODEL_CACHES: Dict[str, Dict[str, Any]] = {}
# Provider name → the model cache it populates (consulted by warm/release in
# tts_tool_lifecycle; a new local engine adds a row here plus a loader in _local_tts_warmers()).
# Piper voices keyed on absolute .onnx path (+cuda flag); KittenTTS on model name.
_piper_voice_cache: Dict[str, Any] = {}
_kittentts_model_cache: Dict[str, Any] = {}
_LOCAL_TTS_MODEL_CACHES["piper"] = _piper_voice_cache
_LOCAL_TTS_MODEL_CACHES["kittentts"] = _kittentts_model_cache
_LOCAL_TTS_MODEL_CACHES: Dict[str, Dict[str, Any]] = {"piper": _piper_voice_cache, "kittentts": _kittentts_model_cache}
def _tts_cache_get_or_load(cache: Dict[str, Any], key: str, load: Callable[[], Any]) -> Any:
"""Get ``key`` from ``cache`` or load it, keeping the cache LRU-bounded.
A hit refreshes recency (pop + reinsert on the insertion-ordered dict); a
miss loads then evicts LRU entries beyond ``_TTS_MODEL_CACHE_MAX``. Callers
holding an evicted reference keep it alive; only the slot is released.
A hit refreshes recency (pop + reinsert on the insertion-ordered dict); a miss loads
then evicts LRU entries beyond ``_TTS_MODEL_CACHE_MAX``. Callers holding an evicted
reference keep it alive; only the slot is released.
"""
if key in cache:
cache[key] = cache.pop(key)
@@ -68,17 +64,19 @@ def _tts_cache_get_or_load(cache: Dict[str, Any], key: str, load: Callable[[], A
return value
# ===========================================================================
def _section(tts_config: Any, key: str) -> Dict[str, Any]:
return (tts_config.get(key) or {}) if isinstance(tts_config, dict) else {}
def _run_helper(cmd: list, timeout: int) -> subprocess.CompletedProcess:
return subprocess.run(
cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout, stdin=subprocess.DEVNULL,
)
# ---------------------------------------------------------------------------
# NeuTTS (subprocess via tools/neutts_synth.py so the ~500MB model exits after use)
# ===========================================================================
def _default_neutts_ref_audio() -> str:
return str(Path(__file__).parent / "neutts_samples" / "jo.wav")
def _default_neutts_ref_text() -> str:
return str(Path(__file__).parent / "neutts_samples" / "jo.txt")
# ---------------------------------------------------------------------------
def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
neutts_config = tts_config.get("neutts") or {}
@@ -87,12 +85,12 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
sys.executable, str(Path(__file__).parent / "neutts_synth.py"),
"--text", text,
"--out", wav_path,
"--ref-audio", neutts_config.get("ref_audio", "") or _default_neutts_ref_audio(),
"--ref-text", neutts_config.get("ref_text", "") or _default_neutts_ref_text(),
"--ref-audio", neutts_config.get("ref_audio", "") or str(_NEUTTS_SAMPLES / "jo.wav"),
"--ref-text", neutts_config.get("ref_text", "") or str(_NEUTTS_SAMPLES / "jo.txt"),
"--model", neutts_config.get("model", "neuphonic/neutts-air-q4-gguf"),
"--device", neutts_config.get("device", "cpu"),
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=120, stdin=subprocess.DEVNULL)
result = _run_helper(cmd, 120)
if result.returncode != 0:
# The synth script reports success lines as "OK:" on stderr too.
error_lines = [l for l in result.stderr.strip().splitlines() if not l.startswith("OK:")]
@@ -100,9 +98,9 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
return _finalize_wav_output(wav_path, output_path)
# ===========================================================================
# ---------------------------------------------------------------------------
# Piper (local neural VITS, 44 languages)
# ===========================================================================
# ---------------------------------------------------------------------------
def _get_piper_voices_dir() -> Path:
"""``<HERMES_HOME>/cache/piper-voices/`` so voice downloads follow profile boundaries."""
@@ -115,13 +113,11 @@ def _get_piper_voices_dir() -> Path:
def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
"""Resolve *voice* (an .onnx path or a voice name) to a concrete .onnx file.
Names like ``en_US-lessac-medium`` are downloaded into *download_dir* on
first use via ``python -m piper.download_voices``. Raises RuntimeError
when the model can't be located or downloaded.
Names like ``en_US-lessac-medium`` are downloaded into *download_dir* on first use via
``python -m piper.download_voices``. Raises RuntimeError when the model can't be
located or downloaded.
"""
if not voice:
voice = DEFAULT_PIPER_VOICE
voice = voice or DEFAULT_PIPER_VOICE
candidate = Path(voice).expanduser()
if candidate.suffix.lower() == ".onnx" and candidate.exists():
return str(candidate)
@@ -132,19 +128,14 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
logger.info("[Piper] Downloading voice '%s' to %s (first use)", voice, download_dir)
try:
result = subprocess.run(
[sys.executable, "-m", "piper.download_voices", voice,
"--download-dir", str(download_dir)],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=300,
stdin=subprocess.DEVNULL,
result = _run_helper(
[sys.executable, "-m", "piper.download_voices", voice, "--download-dir", str(download_dir)], 300,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"Piper voice download timed out after 300s for '{voice}'") from exc
if result.returncode != 0:
stderr = (result.stderr or "").strip() or "no stderr output"
raise RuntimeError(f"Piper voice download failed for '{voice}': {stderr[:400]}")
if not cached.exists():
raise RuntimeError(
f"Piper voice download completed but {cached} is missing — "
@@ -157,20 +148,19 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
def _load_piper_voice_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[str, Any]]:
"""Resolve + load (or fetch from cache) the Piper voice ``tts_config`` selects.
Shared by synthesis and ``warm_tts_provider`` so a warm-up fills exactly
the cache slot the next synthesis hits. Returns ``(voice, piper_config)``.
Shared by synthesis and ``warm_tts_provider`` so a warm-up fills exactly the cache
slot the next synthesis hits. Returns ``(voice, piper_config)``.
"""
PiperVoice = _origin()._import_piper()
piper_config = tts_config.get("piper") or {} if isinstance(tts_config, dict) else {}
piper_config = _section(tts_config, "piper")
voice_name = piper_config.get("voice") or DEFAULT_PIPER_VOICE
download_dir = Path(piper_config.get("voices_dir") or _get_piper_voices_dir()).expanduser()
download_dir.mkdir(parents=True, exist_ok=True)
use_cuda = bool(piper_config.get("use_cuda", False))
model_path = _resolve_piper_voice_path(voice_name, download_dir)
# speaker_id is applied per call via syn_config, so one PiperVoice
# instance serves every speaker and stays out of the cache key.
# speaker_id is applied per call via syn_config, so one PiperVoice instance serves
# every speaker and stays out of the cache key.
cache_key = f"{model_path}::cuda={use_cuda}"
def _load_piper_voice():
@@ -179,8 +169,10 @@ def _load_piper_voice_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[
logger.info("[Piper] Voice loaded")
return v
voice = _tts_cache_get_or_load(_piper_voice_cache, cache_key, _load_piper_voice)
return voice, piper_config
return _tts_cache_get_or_load(_piper_voice_cache, cache_key, _load_piper_voice), piper_config
_PIPER_ADVANCED_KNOBS = ("length_scale", "noise_scale", "noise_w_scale", "volume", "normalize_audio", "speaker_id")
def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
@@ -188,19 +180,15 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
voice, piper_config = _load_piper_voice_for_config(tts_config)
# Bad speaker_id input drops to 0 (Piper's default); booleans are rejected
# outright since True/False would silently coerce to 1/0.
# Bad speaker_id input drops to 0 (Piper's default); booleans are rejected outright
# since True/False would silently coerce to 1/0.
_raw_speaker = piper_config.get("speaker_id", 0)
speaker_id = 0 if isinstance(_raw_speaker, bool) or not isinstance(_raw_speaker, int) else _raw_speaker
# Only build a SynthesisConfig when an advanced knob is configured, so we
# don't depend on a newer piper-tts than the user's unless we must.
# Only build a SynthesisConfig when an advanced knob is configured, so we don't
# depend on a newer piper-tts than the user's unless we must.
syn_config = None
has_advanced = any(
k in piper_config
for k in ("length_scale", "noise_scale", "noise_w_scale", "volume", "normalize_audio", "speaker_id")
)
if has_advanced:
if any(k in piper_config for k in _PIPER_ADVANCED_KNOBS):
try:
from piper import SynthesisConfig # type: ignore
syn_config = SynthesisConfig(
@@ -212,10 +200,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
speaker_id=speaker_id,
)
except ImportError:
logger.warning(
"[Piper] SynthesisConfig not available in this piper-tts "
"version — advanced knobs ignored"
)
logger.warning("[Piper] SynthesisConfig not available in this piper-tts version — advanced knobs ignored")
wav_path = _wav_sidecar_path(output_path)
with wave.open(wav_path, "wb") as wav_file:
@@ -226,15 +211,14 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
return _finalize_wav_output(wav_path, output_path)
# ===========================================================================
# ---------------------------------------------------------------------------
# KittenTTS (local ONNX, 25-80MB models, CPU only)
# ===========================================================================
# ---------------------------------------------------------------------------
def _load_kittentts_model_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[str, Any]]:
"""Load (or fetch from cache) the KittenTTS model; returns ``(model, kittentts_config)``."""
KittenTTS = _origin()._import_kittentts()
kt_config = tts_config.get("kittentts", {}) if isinstance(tts_config, dict) else {}
kt_config = kt_config or {}
kt_config = _section(tts_config, "kittentts")
model_name = kt_config.get("model", DEFAULT_KITTENTTS_MODEL)
def _load_kittentts_model():
@@ -243,8 +227,7 @@ def _load_kittentts_model_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, D
logger.info("[KittenTTS] Model loaded successfully")
return m
model = _tts_cache_get_or_load(_kittentts_model_cache, model_name, _load_kittentts_model)
return model, kt_config
return _tts_cache_get_or_load(_kittentts_model_cache, model_name, _load_kittentts_model), kt_config
def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:

View File

@@ -1,12 +1,10 @@
"""OpenAI-compatible TTS backends for ``tools.tts_tool``: OpenAI and DeepInfra.
Also owns the managed-gateway (Nous portal ``openai-audio`` proxy) route
selection that decides where the OpenAI client points. Seams that tests
monkeypatch on the origin module (``_load_tts_config``, ``read_selection``,
``resolve_openai_audio_api_key``, ``resolve_managed_tool_gateway``,
``_import_openai_client``, ``_resolve_openai_audio_client_config``,
``_resolve_provider_key``, ``_generate_openai_tts``) are resolved through
:func:`_origin` at call time so those patches keep applying.
Also owns the managed-gateway (Nous portal ``openai-audio`` proxy) route selection that
decides where the OpenAI client points. Seams tests monkeypatch on the origin module
(``_load_tts_config``, ``read_selection``, ``resolve_openai_audio_api_key``,
``resolve_managed_tool_gateway``, ``_import_openai_client``, ``_resolve_provider_key``,
``_generate_openai_tts``, ...) are resolved through :func:`_origin` at call time.
"""
from __future__ import annotations
@@ -17,9 +15,7 @@ from typing import Any, Dict, Optional
from urllib.parse import urljoin
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
nous_tool_gateway_unavailable_message,
selection_error,
NOUS_MANAGED_PROVIDER, nous_tool_gateway_unavailable_message, selection_error,
)
from tools.tts_tool_providers import _tts_response_format_from_path
@@ -34,8 +30,7 @@ def _origin():
DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts"
# The managed OpenAI audio gateway (Nous portal proxy) only proxies these
# speech models; anything else is 400 "Unsupported managed OpenAI speech model".
# The managed OpenAI audio gateway only proxies these; anything else is 400 "Unsupported".
MANAGED_OPENAI_TTS_MODELS = frozenset({"gpt-4o-mini-tts"})
DEFAULT_OPENAI_VOICE = "alloy"
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
@@ -50,63 +45,50 @@ def _managed_openai_audio_route() -> Optional[tuple]:
return gateway.nous_user_token, urljoin(f"{gateway.gateway_origin.rstrip('/')}/", "v1"), True
def _openai_section(tts_config: Any, key: str) -> Dict[str, Any]:
"""``tts.<key>`` as a dict (``tts.openai: null`` in YAML yields None — coalesce so .get() is safe)."""
section = tts_config.get(key) if isinstance(tts_config, dict) else None
return section if isinstance(section, dict) else {}
def _resolve_openai_audio_client_config() -> tuple[str, str, bool]:
"""Return ``(api_key, base_url, is_managed)`` for the OpenAI audio client.
``is_managed`` marks the Nous managed audio gateway (a restricted proxy)
so callers can coerce the request to what it supports. Strict selection
semantics on the stored ``tts`` provider:
- ``"nous"`` → managed gateway ONLY; unentitled/unreachable is an error.
- any other stored provider → direct credentials ONLY (``tts.openai.api_key``
then ``VOICE_TOOLS_OPENAI_KEY``/``OPENAI_API_KEY``); no silent managed fallback.
- never-configured tts section → legacy ladder: config key → env key → managed.
``is_managed`` marks the Nous managed audio gateway (a restricted proxy) so callers can
coerce the request to what it supports. Strict selection semantics on the stored
``tts`` provider: ``"nous"`` → managed gateway ONLY (unentitled/unreachable is an
error); any other stored provider → direct credentials ONLY (``tts.openai.api_key``
then ``VOICE_TOOLS_OPENAI_KEY``/``OPENAI_API_KEY``), no silent managed fallback;
never-configured tts section → legacy ladder: config key → env key → managed.
"""
tts_config = _origin()._load_tts_config()
openai_cfg = (tts_config.get("openai") if isinstance(tts_config, dict) else None) or {}
cfg_api_key = openai_cfg.get("api_key") or ""
cfg_base_url = openai_cfg.get("base_url") or ""
direct_base = cfg_base_url or DEFAULT_OPENAI_BASE_URL
selected = _origin().read_selection("tts")
origin = _origin()
openai_cfg = _openai_section(origin._load_tts_config(), "openai")
direct_base = openai_cfg.get("base_url") or DEFAULT_OPENAI_BASE_URL
selected = origin.read_selection("tts")
if selected == NOUS_MANAGED_PROVIDER:
route = _managed_openai_audio_route()
if route is None:
raise ValueError(selection_error(
"tts",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
"tts", NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or unreachable)",
))
return route
if cfg_api_key:
return cfg_api_key, direct_base, False
direct_api_key = _origin().resolve_openai_audio_api_key()
direct_api_key = openai_cfg.get("api_key") or origin.resolve_openai_audio_api_key()
if direct_api_key:
return direct_api_key, direct_base, False
if selected is not None:
raise ValueError(selection_error(
"tts",
selected,
"neither tts.openai.api_key in config nor "
"VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set",
"tts", selected,
"neither tts.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set",
))
route = _managed_openai_audio_route()
if route is None:
message = (
"Neither tts.openai.api_key in config nor "
"VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set"
)
if _origin().managed_nous_tools_enabled():
message += (
". "
+ nous_tool_gateway_unavailable_message(
"managed OpenAI audio for TTS",
)
)
message = "Neither tts.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set"
if origin.managed_nous_tools_enabled():
message += ". " + nous_tool_gateway_unavailable_message("managed OpenAI audio for TTS")
raise ValueError(message)
return route
@@ -130,18 +112,16 @@ def _generate_openai_tts(
model: Optional[str] = None,
voice: Optional[str] = None,
speed: Optional[float] = None,
instructions: Optional[str] = None,
) -> str:
instructions: Optional[str] = None) -> str:
"""Generate audio via the OpenAI ``audio.speech.create`` SDK shape.
Explicit kwargs let OpenAI-compatible backends (DeepInfra) pass their own
credentials/model/voice and skip ``_resolve_openai_audio_client_config``
(the managed-gateway path). When None: ``api_key`` comes from the OpenAI
auth chain, ``base_url`` from ``tts.openai.base_url`` then the auth-chain
fallback then the OpenAI default, model/voice/speed from ``tts.openai``
(speed falling back to global ``tts.speed``). ``instructions`` is
forwarded only when truthy so ``tts-1`` and strict OpenAI-compatible
servers that reject unknown kwargs are unaffected.
credentials/model/voice and skip ``_resolve_openai_audio_client_config`` (the
managed-gateway path). When None: ``api_key`` comes from the OpenAI auth chain,
``base_url`` from ``tts.openai.base_url`` then the auth-chain fallback then the OpenAI
default, model/voice/speed from ``tts.openai`` (speed falling back to global
``tts.speed``). ``instructions`` is forwarded only when truthy so ``tts-1`` and strict
OpenAI-compatible servers that reject unknown kwargs are unaffected.
"""
fallback_base: Optional[str] = None
is_managed = False
@@ -149,31 +129,23 @@ def _generate_openai_tts(
if api_key is None:
api_key, fallback_base, is_managed = _origin()._resolve_openai_audio_client_config()
# ``tts.openai: null`` in YAML yields None — coalesce so .get() is safe.
oai_config = (tts_config.get("openai") if isinstance(tts_config, dict) else None) or {}
oai_config = _openai_section(tts_config, "openai")
if model is None:
model = oai_config.get("model", DEFAULT_OPENAI_MODEL)
if voice is None:
voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE)
config_base_url = oai_config.get("base_url")
if base_url is None:
# Config override beats the auth-chain fallback; an explicit arg
# (DeepInfra) skipped this block and always wins.
# Config override beats the auth-chain fallback; an explicit arg (DeepInfra) always wins.
base_url = config_base_url or fallback_base or DEFAULT_OPENAI_BASE_URL
if speed is None:
speed_default = tts_config.get("speed", 1.0) if isinstance(tts_config, dict) else 1.0
speed = float(oai_config.get("speed", speed_default))
language = oai_config.get("language")
# The managed gateway only proxies MANAGED_OPENAI_TTS_MODELS; coerce a
# direct-OpenAI model (e.g. "tts-1-hd") unless the user redirected
# base_url to their own endpoint.
if (
is_managed
and not explicit_base_url
and not config_base_url
and model not in MANAGED_OPENAI_TTS_MODELS
):
# The managed gateway only proxies MANAGED_OPENAI_TTS_MODELS; coerce a direct-OpenAI
# model (e.g. "tts-1-hd") unless the user redirected base_url to their own endpoint.
if is_managed and not explicit_base_url and not config_base_url and model not in MANAGED_OPENAI_TTS_MODELS:
logger.warning(
"TTS: managed OpenAI audio gateway does not support model %r; "
"falling back to %s. Set VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY "
@@ -182,8 +154,6 @@ def _generate_openai_tts(
)
model = DEFAULT_OPENAI_MODEL
response_format = _tts_response_format_from_path(output_path)
OpenAIClient = _origin()._import_openai_client()
client = OpenAIClient(api_key=api_key, base_url=base_url)
try:
@@ -191,7 +161,7 @@ def _generate_openai_tts(
"model": model,
"voice": voice,
"input": text,
"response_format": response_format,
"response_format": _tts_response_format_from_path(output_path),
"extra_headers": {"x-idempotency-key": str(uuid.uuid4())},
}
if speed != 1.0:
@@ -213,22 +183,15 @@ def _generate_openai_tts(
def _generate_deepinfra_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
"""Resolve DeepInfra credentials/model, then delegate to the OpenAI handler.
DeepInfra's audio endpoint is OpenAI-compatible. Model ids come live from
the shared ``hermes_cli.models`` catalog helpers (no hardcoded ids, so
retired models disappear without a patch).
DeepInfra's audio endpoint is OpenAI-compatible. Model ids come live from the shared
``hermes_cli.models`` catalog helpers (no hardcoded ids, so retired models disappear
without a patch).
"""
api_key = _origin()._resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra")
if not api_key:
raise ValueError(
"DEEPINFRA_API_KEY not set. Run `hermes setup` to configure, "
"or set the env var directly."
)
# ``tts.deepinfra: null`` yields None (no DEFAULT_CONFIG block to merge over).
di_config = tts_config.get("deepinfra") if isinstance(tts_config, dict) else None
if not isinstance(di_config, dict):
di_config = {}
raise ValueError("DEEPINFRA_API_KEY not set. Run `hermes setup` to configure, or set the env var directly.")
di_config = _openai_section(tts_config, "deepinfra")
from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids
model = di_config.get("model")

View File

@@ -1,10 +1,9 @@
"""Plugin-registered TTS providers for ``tools.tts_tool``.
Routes ``tts.provider: <name>`` values that are neither built-in nor a
``type: command`` entry to a :class:`agent.tts_provider.TTSProvider`
registered by a plugin. Discovery goes through
``hermes_cli.plugins._ensure_plugins_discovered`` (imported lazily so the
tool module stays importable without the plugin machinery).
Routes ``tts.provider: <name>`` values that are neither built-in nor a ``type: command``
entry to a :class:`agent.tts_provider.TTSProvider` registered by a plugin. Discovery goes
through ``hermes_cli.plugins._ensure_plugins_discovered`` (imported lazily so the tool
module stays importable without the plugin machinery).
"""
from __future__ import annotations
@@ -25,10 +24,9 @@ logger = logging.getLogger("tools.tts_tool")
def _lookup_plugin_provider(key: str, *, discover: bool = True, retry: bool = False):
"""The registered ``TTSProvider`` named *key*, or None.
``discover`` runs plugin discovery first; ``retry`` re-discovers with
``force=True`` on a miss (long-lived sessions may have discovered plugins
before this one was installed/enabled). Raises on registry/discovery
failure — callers decide whether that is fatal.
``discover`` runs plugin discovery first; ``retry`` re-discovers with ``force=True`` on
a miss (long-lived sessions may have discovered plugins before this one was
installed/enabled). Raises on registry/discovery failure — callers decide if fatal.
"""
from agent.tts_registry import get_provider
@@ -43,24 +41,14 @@ def _lookup_plugin_provider(key: str, *, discover: bool = True, retry: bool = Fa
return plugin_provider
def _dispatch_to_plugin_provider(
text: str,
output_path: str,
provider: str,
tts_config: Dict[str, Any],
) -> Optional[str]:
def _dispatch_to_plugin_provider(text: str, output_path: str, provider: str, tts_config: Dict[str, Any]) -> Optional[str]:
"""Route to a plugin-registered TTS provider; None means "fall through".
Invariants enforced here even though the caller checks them too, so a
caller refactor can't silently break them:
1. Built-in names never reach the plugin registry.
2. A same-named ``type: command`` provider wins over a plugin.
3. Dispatch fires only for a registered :class:`TTSProvider` whose name
equals the configured value; unknown names return None.
Plugin exceptions propagate — the outer ``text_to_speech_tool`` converts
them to the standard error envelope.
Invariants enforced here even though the caller checks them too, so a caller refactor
can't silently break them: built-in names never reach the plugin registry; a same-named
``type: command`` provider wins over a plugin; dispatch fires only for a registered
:class:`TTSProvider` whose name equals the configured value (unknown names -> None).
Plugin exceptions propagate — ``text_to_speech_tool`` converts them to the error envelope.
"""
if not provider:
return None
@@ -77,8 +65,8 @@ def _dispatch_to_plugin_provider(
if plugin_provider is None:
return None
# voice/model/speed/format are optional per the TTSProvider.synthesize
# contract; providers fall back to their own defaults on None.
# voice/model/speed/format are optional per the TTSProvider.synthesize contract;
# providers fall back to their own defaults on None.
cfg = tts_config if isinstance(tts_config, dict) else {}
voice = cfg.get("voice")
model = cfg.get("model")
@@ -87,8 +75,7 @@ def _dispatch_to_plugin_provider(
logger.info("Generating speech with plugin TTS provider '%s'...", key)
written = plugin_provider.synthesize(
text,
output_path,
text, output_path,
voice=voice if isinstance(voice, str) and voice else None,
model=model if isinstance(model, str) and model else None,
speed=float(speed) if isinstance(speed, (int, float)) else None,
@@ -99,10 +86,7 @@ def _dispatch_to_plugin_provider(
def _plugin_provider_is_voice_compatible(provider: str) -> bool:
"""True when the registered plugin provider opts into voice-bubble delivery.
Any registry/property failure means False (safe default, like command providers).
"""
"""True when the registered plugin provider opts into voice-bubble delivery (any failure -> False)."""
if not provider:
return False
key = provider.lower().strip()
@@ -110,9 +94,7 @@ def _plugin_provider_is_voice_compatible(provider: str) -> bool:
return False
try:
plugin_provider = _lookup_plugin_provider(key, discover=False)
if plugin_provider is None:
return False
return bool(plugin_provider.voice_compatible)
return plugin_provider is not None and bool(plugin_provider.voice_compatible)
except Exception as exc: # noqa: BLE001
logger.debug("tts plugin voice_compatible check failed for '%s': %s", key, exc)
return False

View File

@@ -533,8 +533,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
payload = {"model": model, "text": text, "voice_id": voice_id}
response = _post_json(base_url, payload, {
"Content-Type": "application/json",
"Authorization": f"Bearer {runtime.api_key}",
"Content-Type": "application/json", "Authorization": f"Bearer {runtime.api_key}"
})
if is_t2a_v2: