Files
hermes-agent/hermes_cli/setup_tts.py
ethernet a6ae6ace51 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.github/workflows/js-tests.yml
#	agent/model_metadata.py
#	apps/desktop/electron/main.ts
#	apps/desktop/scripts/bundle-electron-main.mjs
#	apps/desktop/src/app/settings/about-settings.tsx
#	apps/desktop/src/app/settings/gateway-settings.test.tsx
#	apps/desktop/src/app/settings/gateway-settings.tsx
#	apps/desktop/src/app/updates-overlay.tsx
#	gateway/shutdown_flush.py
#	hermes_bootstrap.py
#	hermes_cli/local_runtime/binaries.py
#	hermes_cli/main.py
#	hermes_cli/managed_uv.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_cmd_deps.py
#	hermes_cli/update_cmd_fleet.py
#	hermes_cli/update_cmd_maint.py
#	hermes_cli/update_receipt.py
#	hermes_cli/update_serve_obligations.py
#	hermes_constants.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_pending_supervisor_recovery.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_update_desktop_stale_warning.py
#	tests/hermes_cli/test_update_fleet_restart_pending.py
#	tests/hermes_state/test_hermes_state.py
#	tests/tools/test_tirith_security.py
#	tools/bot_relay.py
#	tools/checkpoint_manager.py
#	tools/write_approval.py
#	website/docs/getting-started/updating.md
#	website/docs/reference/environment-variables.md
2026-09-18 17:26:10 -04:00

274 lines
13 KiB
Python

"""Text-to-speech provider setup (provider picker, API-key prompts, local engine installs, xAI OAuth).
setup.py names are resolved through the module object so test patches on ``hermes_cli.setup.<name>``
take effect; setup.py re-exports the public entry points."""
import logging
import shutil
import subprocess
import sys
from tools import tool_backend_helpers
from hermes_cli import nous_subscription
logger = logging.getLogger("hermes_cli.setup")
def _install_tts_extra(extra: str) -> bool:
"""Record the engine in PM's dependency union; activate it on next launch."""
import pm
try:
pm.sync_venv([extra], explicit=True)
except (pm.InstallError, OSError, ValueError) as exc:
_setup.print_error(f"Failed to install {extra}: {exc}")
_setup.print_info("Retry with: hermes setup tts")
return False
_setup.print_success(f"{extra} installed. Restart Hermes to use it.")
return True
# sys.platform -> (manual install hint, install command); anything else uses "linux".
_ESPEAK_INSTALL = {
"darwin": ("Install with: brew install espeak-ng", ["brew", "install", "espeak-ng"]),
"win32": ("Install with: choco install espeak-ng", ["choco", "install", "espeak-ng", "-y"]),
"linux": ("Install with: sudo apt install espeak-ng", ["sudo", "apt", "install", "-y", "espeak-ng"]),
}
def _install_neutts_deps() -> bool:
"""Install NeuTTS dependencies with user approval. Returns True on success."""
from pm.extras import extra_supported
if not extra_supported("neutts"):
_setup.print_error("NeuTTS is not supported by this Python/platform.")
return False
if not (shutil.which("espeak-ng") or shutil.which("espeak")):
hint, install_cmd = _ESPEAK_INSTALL.get(sys.platform, _ESPEAK_INSTALL["linux"])
print()
_setup.print_warning("NeuTTS requires espeak-ng for phonemization.")
_setup.print_info(hint)
print()
if _setup.prompt_yes_no("Install espeak-ng now?", True):
try:
subprocess.run(install_cmd, check=True)
_setup.print_success("espeak-ng installed")
except (subprocess.CalledProcessError, FileNotFoundError) as e:
_setup.print_warning(f"Could not install espeak-ng automatically: {e}")
_setup.print_info("Please install it manually and re-run setup.")
return False
else:
_setup.print_warning("espeak-ng is required for NeuTTS. Install it manually before using NeuTTS.")
_setup._info(None, "Installing neutts Python package...",
"This will also download the TTS model (~300MB) on first use.", None)
return _install_tts_extra("neutts")
def _install_kittentts_deps() -> bool:
"""Install KittenTTS dependencies with user approval. Returns True on success."""
_setup._info(None, "Installing kittentts Python package (~25-80MB model downloaded on first use)...", None)
return _install_tts_extra("kittentts")
def _xai_oauth_logged_in_for_setup() -> bool:
"""True iff xAI Grok OAuth credentials are stored locally, so TTS/STT setup can skip the
API-key prompt for users who logged in via ``hermes model`` -> xAI Grok OAuth."""
try:
from hermes_cli.auth import get_xai_oauth_auth_status
return bool(get_xai_oauth_auth_status().get("logged_in"))
except Exception:
return False
def _run_xai_oauth_login_from_setup() -> bool:
"""Run the xAI Grok OAuth device-code login from inside the setup wizard. Saves OAuth tokens
only — does **not** switch the active provider or rewrite ``model.provider`` (callers only need
credentials for side tools). False on any failure (caller falls back)."""
try:
from hermes_cli.auth import (
_is_remote_session, _save_xai_oauth_tokens, _xai_oauth_device_code_login,
unsuppress_credential_source)
except Exception as exc:
_setup.print_warning(f"xAI Grok OAuth helpers unavailable: {exc}")
return False
_setup._info(None, "Signing in to xAI Grok OAuth (SuperGrok / Premium+)...")
try:
creds = _xai_oauth_device_code_login(open_browser=not _is_remote_session())
_save_xai_oauth_tokens(
creds["tokens"], discovery=creds.get("discovery"), redirect_uri=creds.get("redirect_uri", ""),
last_refresh=creds.get("last_refresh"), auth_mode="oauth_device_code", set_active=False)
# Mirror model/dashboard re-login: clear device_code suppression so the pool can seed
# from the singleton after a prior `auth remove`.
unsuppress_credential_source("xai-oauth", "device_code")
return True
except Exception as exc:
_setup.print_warning(f"xAI Grok OAuth login failed: {exc}")
return False
_TTS_PROVIDER_CHOICES = [
("edge", "Edge TTS (free, cloud-based, no setup needed)"),
("elevenlabs", "ElevenLabs (premium quality, needs API key)"),
("openai", "OpenAI TTS (good quality, needs API key)"),
("xai", "xAI TTS (Grok voices — OAuth login or API key)"),
("minimax", "MiniMax TTS (high quality with voice cloning, needs API key)"),
("mistral", "Mistral Voxtral TTS (multilingual, native Opus, needs API key)"),
("gemini", "Google Gemini TTS (30 prebuilt voices, prompt-controllable, needs API key)"),
("neutts", "NeuTTS (local on-device, free, ~300MB model download)"),
("kittentts", "KittenTTS (local on-device, free, lightweight ~25-80MB ONNX)")]
# Short label = menu label minus its parenthetical ("Edge TTS", "Mistral Voxtral TTS", ...).
_TTS_PROVIDER_LABELS = {key: label.split(" (")[0] for key, label in _TTS_PROVIDER_CHOICES}
# provider -> (env vars that satisfy it, env var to save, prompt, success line, pre-prompt hint)
_TTS_API_KEY_PROVIDERS = {
"elevenlabs": (("ELEVENLABS_API_KEY",), "ELEVENLABS_API_KEY", "ElevenLabs API key",
"ElevenLabs API key saved", ""),
"openai": (("VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"), "VOICE_TOOLS_OPENAI_KEY",
"OpenAI API key for TTS", "OpenAI TTS API key saved", ""),
"minimax": (("MINIMAX_API_KEY",), "MINIMAX_API_KEY", "MiniMax API key for TTS",
"MiniMax TTS API key saved", ""),
"mistral": (("MISTRAL_API_KEY",), "MISTRAL_API_KEY", "Mistral API key for TTS",
"Mistral TTS API key saved", ""),
"gemini": (("GEMINI_API_KEY", "GOOGLE_API_KEY"), "GEMINI_API_KEY", "Gemini API key for TTS",
"Gemini TTS API key saved", "Get a free API key at https://aistudio.google.com/app/apikey"),
}
# provider -> (module, display name, requirement lines, install question, installer)
_TTS_LOCAL_PROVIDERS = {
"neutts": ("neutts", "NeuTTS",
("NeuTTS requires:", " • Python package: neutts (~50MB install + ~300MB model on first use)",
" • System package: espeak-ng (phonemizer)"),
"Install NeuTTS dependencies now?", _install_neutts_deps),
"kittentts": ("kittentts", "KittenTTS",
("KittenTTS is lightweight (~25-80MB, CPU-only, no API key required).",
"Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo"),
"Install KittenTTS now?", _install_kittentts_deps)}
def _tts_api_key_step(selected: str) -> str:
"""Ensure the key for an API-key TTS provider exists; fall back to edge otherwise."""
env_vars, save_var, prompt_label, saved_msg, hint = _TTS_API_KEY_PROVIDERS[selected]
if any(_setup.get_env_value(v) for v in env_vars):
return selected
print()
if hint:
_setup.print_info(hint)
api_key = _setup.prompt(prompt_label, password=True)
if api_key:
_setup.save_env_value(save_var, api_key)
_setup.print_success(saved_msg)
return selected
_setup.print_warning("No API key provided. Falling back to Edge TTS.")
return "edge"
def _tts_local_install_step(selected: str) -> str:
"""Offer a local engine install; retain an unsupported explicit selection."""
from pm.extras import extra_supported
module, name, lines, question, installer = _TTS_LOCAL_PROVIDERS[selected]
if not extra_supported(selected):
_setup.print_warning(
f"{name} is not supported by this Python/platform. "
"Your provider selection is saved, but speech requires a supported engine.")
return selected
if _setup._module_installed(module):
_setup.print_success(f"{name} is already installed")
return selected
print()
for line in lines:
_setup.print_info(line)
print()
if not _setup.prompt_yes_no(question, True):
_setup.print_info(f"Skipping install. Set tts.provider to '{selected}' after installing manually.")
return "edge"
if not installer():
_setup.print_warning(f"{name} installation incomplete. Falling back to Edge TTS.")
return "edge"
return selected
def _xai_oauth_path():
if _run_xai_oauth_login_from_setup():
_setup.print_success("Logged in — xAI TTS will use these OAuth credentials")
return None
return "xAI Grok OAuth login did not complete. Falling back to Edge TTS."
def _xai_api_key_path():
api_key = _setup.prompt("xAI API key for TTS", password=True)
if api_key:
_setup.save_env_value("XAI_API_KEY", api_key)
_setup.print_success("xAI TTS API key saved")
return None
from hermes_constants import display_hermes_home as _dhh
return ("No xAI API key provided for TTS. Configure XAI_API_KEY via hermes setup model "
f"or {_dhh()}/.env to use xAI TTS. Falling back to Edge TTS.")
def _tts_xai_step(config: dict) -> str:
"""xAI TTS auth. Order: existing XAI_API_KEY > existing OAuth tokens > offer both
paths — matches runtime, where an explicit key wins over the subscription OAuth
bearer (which 403s on metered /v1/tts). See #87045, #113727."""
if _setup.get_env_value("XAI_API_KEY"):
_setup.print_success("xAI TTS will use your existing XAI_API_KEY (preferred over xAI Grok OAuth)")
elif _xai_oauth_logged_in_for_setup():
_setup.print_success("xAI TTS will use your xAI Grok OAuth (SuperGrok / Premium+) credentials")
else:
print()
choice_idx = _setup.prompt_choice(
"How do you want xAI TTS to authenticate?",
choices=["Sign in with xAI Grok OAuth (SuperGrok / Premium+) — browser login",
"Paste an xAI API key (console.x.ai)", "Skip → fallback to Edge TTS"], default=0)
# Each path returns the fallback warning (result is then "edge") or None on success.
fallback = (_xai_oauth_path, _xai_api_key_path, lambda: "xAI TTS skipped. Falling back to Edge TTS.")[
choice_idx if choice_idx in (0, 1) else 2]()
if fallback:
_setup.print_warning(fallback)
return "edge"
print()
voice_id = (_setup.prompt("xAI voice_id (Enter for 'eve', or paste a custom voice ID)") or "").strip()
if voice_id:
config.setdefault("tts", {}).setdefault("xai", {})["voice_id"] = voice_id
_setup.print_success(f"xAI voice_id set to: {voice_id}")
return "xai"
def _setup_tts_provider(config: dict):
"""Interactive TTS provider selection with install flow for local engines."""
current_provider = config.get("tts", {}).get("provider", "edge")
current_label = _TTS_PROVIDER_LABELS.get(current_provider, current_provider)
print()
_setup.print_header("Text-to-Speech Provider (optional)")
_setup._info(f"Current: {current_label}", None)
options = list(_TTS_PROVIDER_CHOICES)
if tool_backend_helpers.managed_nous_tools_enabled() and nous_subscription.get_nous_subscription_features(config).nous_auth_present:
options.insert(0, ("nous-openai",
"Nous Subscription (managed OpenAI TTS, billed to your subscription)"))
choices = [label for _, label in options] + [f"Keep current ({current_label})"]
keep_current_idx = len(choices) - 1
idx = _setup.prompt_choice("Select TTS provider:", choices, keep_current_idx)
if idx == keep_current_idx:
return
selected = options[idx][0]
if selected == "nous-openai":
selected = "openai"
_setup.print_info("OpenAI TTS will use the managed Nous gateway and bill to your subscription.")
if _setup.get_env_value("VOICE_TOOLS_OPENAI_KEY") or _setup.get_env_value("OPENAI_API_KEY"):
_setup.print_warning("Direct OpenAI credentials are still configured and may take precedence "
"until removed from ~/.hermes/.env.")
elif selected in _TTS_LOCAL_PROVIDERS:
selected = _tts_local_install_step(selected)
elif selected in _TTS_API_KEY_PROVIDERS:
selected = _tts_api_key_step(selected)
elif selected == "xai":
selected = _tts_xai_step(config)
config.setdefault("tts", {})["provider"] = selected
_setup.save_config(config)
_setup.print_success(f"TTS provider set to: {_TTS_PROVIDER_LABELS.get(selected, selected)}")
def setup_tts(config: dict):
"""Standalone TTS setup (for 'hermes setup tts')."""
_setup_tts_provider(config)
import hermes_cli.setup as _setup # noqa: E402 (bottom: hermes_cli.setup imports this module)