refactor(tts): extract OpenAI/DeepInfra backend and lifecycle leases into sibling modules
This commit is contained in:
@@ -212,7 +212,7 @@ def test_openai_tts_uses_managed_audio_gateway_when_direct_key_absent(monkeypatc
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||
|
||||
tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py")
|
||||
monkeypatch.setattr(tts_tool.uuid, "uuid4", lambda: "tts-call-123")
|
||||
monkeypatch.setattr(sys.modules["tools.tts_tool_openai"].uuid, "uuid4", lambda: "tts-call-123")
|
||||
output_path = tmp_path / "speech.mp3"
|
||||
tts_tool._generate_openai_tts("hello world", str(output_path), {"openai": {}})
|
||||
|
||||
|
||||
@@ -45,12 +45,8 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile # noqa: F401 — tests/gateway patch ``tts_tool.tempfile.NamedTemporaryFile``
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Any, List, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
@@ -86,7 +82,7 @@ def _resolve_provider_key(env_var: str, provider_id: str) -> str:
|
||||
return resolve_provider_secret(env_var, provider_id, env_getter=get_env_value)
|
||||
|
||||
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway # noqa: F401 — seam patched by tests
|
||||
from tools.tts_command_provider import ( # noqa: F401 — historical names re-exported
|
||||
BUILTIN_TTS_PROVIDERS,
|
||||
COMMAND_TTS_OUTPUT_FORMATS,
|
||||
@@ -108,13 +104,11 @@ from tools.tts_command_provider import ( # noqa: F401 — historical names re-e
|
||||
run_command_provider as _run_command_tts,
|
||||
shell_quote_context as _shell_quote_context,
|
||||
)
|
||||
from tools.tool_backend_helpers import (
|
||||
from tools.tool_backend_helpers import ( # noqa: F401 — seams patched by tests, resolved via tts_tool_openai._origin()
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
managed_nous_tools_enabled,
|
||||
nous_tool_gateway_unavailable_message,
|
||||
read_selection,
|
||||
resolve_openai_audio_api_key,
|
||||
selection_error,
|
||||
)
|
||||
from tools.tts_tool_delivery import ( # noqa: F401 — historical names re-exported
|
||||
FALLBACK_MAX_TEXT_LENGTH,
|
||||
@@ -175,6 +169,31 @@ from tools.tts_tool_local import ( # noqa: F401 — historical names re-exporte
|
||||
from tools.tts_tool_speaker import ( # noqa: F401 — historical names re-exported
|
||||
stream_tts_to_speaker,
|
||||
)
|
||||
from tools.tts_tool_openai import ( # noqa: F401 — historical names re-exported
|
||||
DEFAULT_DEEPINFRA_TTS_VOICE,
|
||||
DEFAULT_OPENAI_BASE_URL,
|
||||
DEFAULT_OPENAI_MODEL,
|
||||
DEFAULT_OPENAI_VOICE,
|
||||
MANAGED_OPENAI_TTS_MODELS,
|
||||
_generate_deepinfra_tts,
|
||||
_generate_openai_tts,
|
||||
_has_openai_audio_backend,
|
||||
_managed_openai_audio_route,
|
||||
_resolve_openai_audio_client_config,
|
||||
)
|
||||
from tools.tts_tool_lifecycle import ( # noqa: F401 — historical names re-exported
|
||||
_lazy_sdk_feature_for_provider,
|
||||
_local_tts_warmers,
|
||||
_reset_tts_leases_for_tests,
|
||||
_signal_user_tts_provider,
|
||||
_tts_lease_lock,
|
||||
_tts_leases,
|
||||
acquire_tts_lease,
|
||||
release_tts_lease,
|
||||
release_tts_provider,
|
||||
tts_lease_holders,
|
||||
warm_tts_provider,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy imports -- providers are imported only when actually used to avoid
|
||||
@@ -261,14 +280,6 @@ def _check_piper_available() -> bool:
|
||||
# Defaults
|
||||
# ===========================================================================
|
||||
DEFAULT_PROVIDER = "edge"
|
||||
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".
|
||||
MANAGED_OPENAI_TTS_MODELS = frozenset({"gpt-4o-mini-tts"})
|
||||
DEFAULT_OPENAI_VOICE = "alloy"
|
||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
# DeepInfra base URL is resolved via hermes_cli.models.deepinfra_base_url (shared).
|
||||
DEFAULT_DEEPINFRA_TTS_VOICE = "default"
|
||||
|
||||
|
||||
def _get_default_output_dir() -> str:
|
||||
@@ -515,347 +526,6 @@ def _has_any_command_tts_provider(tts_config: Optional[Dict[str, Any]] = None) -
|
||||
return False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Provider: OpenAI TTS (also every OpenAI-compatible endpoint — DeepInfra
|
||||
# delegates here). Kept in the origin module: it shares the managed-gateway
|
||||
# selection logic below.
|
||||
# ===========================================================================
|
||||
def _generate_openai_tts(
|
||||
text: str,
|
||||
output_path: str,
|
||||
tts_config: Dict[str, Any],
|
||||
*,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
voice: Optional[str] = None,
|
||||
speed: Optional[float] = None,
|
||||
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.
|
||||
"""
|
||||
fallback_base: Optional[str] = None
|
||||
is_managed = False
|
||||
explicit_base_url = base_url is not None
|
||||
if api_key is None:
|
||||
api_key, fallback_base, is_managed = _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 {}
|
||||
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.
|
||||
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
|
||||
):
|
||||
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 "
|
||||
"to use %r directly.",
|
||||
model, DEFAULT_OPENAI_MODEL, model,
|
||||
)
|
||||
model = DEFAULT_OPENAI_MODEL
|
||||
|
||||
response_format = _tts_response_format_from_path(output_path)
|
||||
|
||||
OpenAIClient = _import_openai_client()
|
||||
client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||
try:
|
||||
create_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"voice": voice,
|
||||
"input": text,
|
||||
"response_format": response_format,
|
||||
"extra_headers": {"x-idempotency-key": str(uuid.uuid4())},
|
||||
}
|
||||
if speed != 1.0:
|
||||
create_kwargs["speed"] = max(0.25, min(4.0, speed))
|
||||
if instructions:
|
||||
create_kwargs["instructions"] = instructions
|
||||
if language:
|
||||
create_kwargs["extra_body"] = {"lang_code": language}
|
||||
response = client.audio.speech.create(**create_kwargs)
|
||||
|
||||
response.stream_to_file(output_path)
|
||||
return output_path
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
api_key = _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 = {}
|
||||
|
||||
from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids
|
||||
|
||||
model = di_config.get("model")
|
||||
if not isinstance(model, str) or not model.strip():
|
||||
candidates = deepinfra_model_ids("tts")
|
||||
if not candidates:
|
||||
raise ValueError(
|
||||
"No DeepInfra TTS model available. Pin one in config.yaml "
|
||||
"under tts.deepinfra.model, or check connectivity to "
|
||||
"api.deepinfra.com so the live catalog can be fetched."
|
||||
)
|
||||
model = candidates[0]
|
||||
return _generate_openai_tts(
|
||||
text,
|
||||
output_path,
|
||||
tts_config,
|
||||
api_key=api_key,
|
||||
base_url=deepinfra_base_url(di_config),
|
||||
model=model,
|
||||
voice=di_config.get("voice", DEFAULT_DEEPINFRA_TTS_VOICE),
|
||||
speed=float(di_config.get("speed", tts_config.get("speed", 1.0))),
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Local-engine lifecycle: warm-up / release driven by TTS-output toggles
|
||||
# ===========================================================================
|
||||
#
|
||||
# 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.
|
||||
|
||||
def _local_tts_warmers() -> Dict[str, Callable[[Dict[str, Any]], Any]]:
|
||||
"""Provider name → loader populating that engine's cache slot (same key synthesis uses)."""
|
||||
return {
|
||||
"piper": lambda cfg: _load_piper_voice_for_config(cfg)[0],
|
||||
"kittentts": lambda cfg: _load_kittentts_model_for_config(cfg)[0],
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
_tts_lease_lock = threading.Lock()
|
||||
_tts_leases: set = set()
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if not name or name in BUILTIN_TTS_PROVIDERS:
|
||||
return None
|
||||
cfg = _get_named_provider_config(tts_config, name)
|
||||
try:
|
||||
if _is_command_provider_config(cfg):
|
||||
template = str(cfg.get(f"{hook}_command") or "").strip()
|
||||
if not template:
|
||||
return None
|
||||
command = _render_command_tts_template(template, {
|
||||
"voice": str(cfg.get("voice", "")),
|
||||
"model": str(cfg.get("model", "")),
|
||||
"speed": str(cfg.get("speed", tts_config.get("speed", ""))),
|
||||
})
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
_run_command_tts(command, _get_command_tts_timeout(cfg),
|
||||
env_passthrough=_command_provider_env_passthrough(cfg))
|
||||
except Exception as exc: # noqa: BLE001 — best-effort hook
|
||||
logger.debug("[TTS] %s_command for %s failed: %s", hook, name, exc)
|
||||
|
||||
threading.Thread(target=_run, name=f"tts-{hook}-{name}", daemon=True).start()
|
||||
return hook
|
||||
from agent.tts_registry import get_provider
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
plugin_provider = get_provider(name)
|
||||
if plugin_provider is None:
|
||||
return None
|
||||
getattr(plugin_provider, hook)()
|
||||
return hook
|
||||
except Exception as exc: # noqa: BLE001 — best-effort hook
|
||||
logger.debug("[TTS] %s hook for %s failed: %s", hook, name, exc)
|
||||
return "error"
|
||||
|
||||
|
||||
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`` /
|
||||
``error``. Blocking; UI threads should run it in the background.
|
||||
"""
|
||||
if tts_config is None:
|
||||
tts_config = _load_tts_config()
|
||||
name = (provider or _get_provider(tts_config) or "").lower().strip()
|
||||
result: Dict[str, Any] = {"provider": name, "warmed": False, "action": "noop"}
|
||||
|
||||
warmer = _local_tts_warmers().get(name)
|
||||
if warmer is not None:
|
||||
cache = _LOCAL_TTS_MODEL_CACHES.get(name)
|
||||
before = len(cache) if cache is not None else 0
|
||||
started = time.monotonic()
|
||||
try:
|
||||
warmer(tts_config)
|
||||
except Exception as exc: # engine missing, download failed, bad voice…
|
||||
logger.warning("[TTS] warm-up for %s failed: %s", name, exc)
|
||||
result.update(action="error", error=str(exc))
|
||||
return result
|
||||
after = len(cache) if cache is not None else 0
|
||||
result.update(
|
||||
warmed=True,
|
||||
action="loaded" if after > before else "cached",
|
||||
elapsed_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
logger.info("[TTS] warm-up %s: %s in %dms", name, result["action"], result["elapsed_ms"])
|
||||
return result
|
||||
|
||||
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")
|
||||
return result
|
||||
|
||||
feature = _lazy_sdk_feature_for_provider(name)
|
||||
if feature is not None:
|
||||
try:
|
||||
from tools.lazy_deps import ensure, is_available
|
||||
|
||||
if is_available(feature):
|
||||
result.update(warmed=True, action="cached")
|
||||
else:
|
||||
ensure(feature, prompt=False)
|
||||
result.update(warmed=True, action="installed")
|
||||
except Exception as exc:
|
||||
logger.debug("[TTS] SDK warm-up for %s skipped: %s", name, exc)
|
||||
result.update(action="error", error=str(exc))
|
||||
return result
|
||||
|
||||
|
||||
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>}``.
|
||||
"""
|
||||
name = (provider or "").lower().strip()
|
||||
if not name:
|
||||
tts_config = _load_tts_config()
|
||||
_signal_user_tts_provider(_get_provider(tts_config), tts_config, "release")
|
||||
released = 0
|
||||
for cache_name, cache in _LOCAL_TTS_MODEL_CACHES.items():
|
||||
if name and cache_name != name:
|
||||
continue
|
||||
released += len(cache)
|
||||
cache.clear()
|
||||
if released:
|
||||
logger.info("[TTS] released %d resident local model(s)", released)
|
||||
return {"released": released}
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
with _tts_lease_lock:
|
||||
_tts_leases.add(lease)
|
||||
holders = len(_tts_leases)
|
||||
result = warm_tts_provider(tts_config)
|
||||
result["leases"] = holders
|
||||
return result
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
with _tts_lease_lock:
|
||||
_tts_leases.discard(lease)
|
||||
holders = len(_tts_leases)
|
||||
result: Dict[str, Any] = {"leases": holders, "released": 0}
|
||||
if holders == 0:
|
||||
result["released"] = release_tts_provider()["released"]
|
||||
return result
|
||||
|
||||
|
||||
def tts_lease_holders() -> List[str]:
|
||||
"""Snapshot of live lease names (diagnostics / tests)."""
|
||||
with _tts_lease_lock:
|
||||
return sorted(_tts_leases)
|
||||
|
||||
|
||||
def _reset_tts_leases_for_tests() -> None:
|
||||
with _tts_lease_lock:
|
||||
_tts_leases.clear()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Built-in provider dispatch
|
||||
# ===========================================================================
|
||||
@@ -1429,83 +1099,6 @@ def check_tts_requirements() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _managed_openai_audio_route() -> Optional[tuple]:
|
||||
gateway = resolve_managed_tool_gateway("openai-audio")
|
||||
if gateway is None:
|
||||
return None
|
||||
return gateway.nous_user_token, urljoin(f"{gateway.gateway_origin.rstrip('/')}/", "v1"), True
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
tts_config = _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 = 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)",
|
||||
))
|
||||
return route
|
||||
|
||||
if cfg_api_key:
|
||||
return cfg_api_key, direct_base, False
|
||||
direct_api_key = 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",
|
||||
))
|
||||
|
||||
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 managed_nous_tools_enabled():
|
||||
message += (
|
||||
". "
|
||||
+ nous_tool_gateway_unavailable_message(
|
||||
"managed OpenAI audio for TTS",
|
||||
)
|
||||
)
|
||||
raise ValueError(message)
|
||||
return route
|
||||
|
||||
|
||||
def _has_openai_audio_backend() -> bool:
|
||||
"""Return True when the selected OpenAI audio route is usable."""
|
||||
try:
|
||||
_resolve_openai_audio_client_config()
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Speech text cleanup (shared by voice-mode streaming and gateway auto-TTS)
|
||||
# ===========================================================================
|
||||
|
||||
232
tools/tts_tool_lifecycle.py
Normal file
232
tools/tts_tool_lifecycle.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from tools.tts_command_provider import (
|
||||
BUILTIN_TTS_PROVIDERS,
|
||||
_get_command_tts_timeout,
|
||||
_get_named_provider_config,
|
||||
_is_command_provider_config,
|
||||
command_env_passthrough as _command_provider_env_passthrough,
|
||||
render_command_template as _render_command_tts_template,
|
||||
)
|
||||
from tools.tts_tool_local import _LOCAL_TTS_MODEL_CACHES
|
||||
|
||||
logger = logging.getLogger("tools.tts_tool")
|
||||
|
||||
|
||||
def _origin():
|
||||
"""``tools.tts_tool``, resolved per call so monkeypatched seams there still apply."""
|
||||
from tools import tts_tool
|
||||
|
||||
return tts_tool
|
||||
|
||||
|
||||
_tts_lease_lock = threading.Lock()
|
||||
_tts_leases: set = set()
|
||||
|
||||
|
||||
def _local_tts_warmers() -> Dict[str, Callable[[Dict[str, Any]], Any]]:
|
||||
"""Provider name → loader populating that engine's cache slot (same key synthesis uses)."""
|
||||
return {
|
||||
"piper": lambda cfg: _origin()._load_piper_voice_for_config(cfg)[0],
|
||||
"kittentts": lambda cfg: _origin()._load_kittentts_model_for_config(cfg)[0],
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if not name or name in BUILTIN_TTS_PROVIDERS:
|
||||
return None
|
||||
cfg = _get_named_provider_config(tts_config, name)
|
||||
try:
|
||||
if _is_command_provider_config(cfg):
|
||||
template = str(cfg.get(f"{hook}_command") or "").strip()
|
||||
if not template:
|
||||
return None
|
||||
command = _render_command_tts_template(template, {
|
||||
"voice": str(cfg.get("voice", "")),
|
||||
"model": str(cfg.get("model", "")),
|
||||
"speed": str(cfg.get("speed", tts_config.get("speed", ""))),
|
||||
})
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
_origin()._run_command_tts(command, _get_command_tts_timeout(cfg),
|
||||
env_passthrough=_command_provider_env_passthrough(cfg))
|
||||
except Exception as exc: # noqa: BLE001 — best-effort hook
|
||||
logger.debug("[TTS] %s_command for %s failed: %s", hook, name, exc)
|
||||
|
||||
threading.Thread(target=_run, name=f"tts-{hook}-{name}", daemon=True).start()
|
||||
return hook
|
||||
from agent.tts_registry import get_provider
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
plugin_provider = get_provider(name)
|
||||
if plugin_provider is None:
|
||||
return None
|
||||
getattr(plugin_provider, hook)()
|
||||
return hook
|
||||
except Exception as exc: # noqa: BLE001 — best-effort hook
|
||||
logger.debug("[TTS] %s hook for %s failed: %s", hook, name, exc)
|
||||
return "error"
|
||||
|
||||
|
||||
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`` /
|
||||
``error``. Blocking; UI threads should run it in the background.
|
||||
"""
|
||||
if tts_config is None:
|
||||
tts_config = _origin()._load_tts_config()
|
||||
name = (provider or _origin()._get_provider(tts_config) or "").lower().strip()
|
||||
result: Dict[str, Any] = {"provider": name, "warmed": False, "action": "noop"}
|
||||
|
||||
warmer = _local_tts_warmers().get(name)
|
||||
if warmer is not None:
|
||||
cache = _LOCAL_TTS_MODEL_CACHES.get(name)
|
||||
before = len(cache) if cache is not None else 0
|
||||
started = time.monotonic()
|
||||
try:
|
||||
warmer(tts_config)
|
||||
except Exception as exc: # engine missing, download failed, bad voice…
|
||||
logger.warning("[TTS] warm-up for %s failed: %s", name, exc)
|
||||
result.update(action="error", error=str(exc))
|
||||
return result
|
||||
after = len(cache) if cache is not None else 0
|
||||
result.update(
|
||||
warmed=True,
|
||||
action="loaded" if after > before else "cached",
|
||||
elapsed_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
logger.info("[TTS] warm-up %s: %s in %dms", name, result["action"], result["elapsed_ms"])
|
||||
return result
|
||||
|
||||
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")
|
||||
return result
|
||||
|
||||
feature = _lazy_sdk_feature_for_provider(name)
|
||||
if feature is not None:
|
||||
try:
|
||||
from tools.lazy_deps import ensure, is_available
|
||||
|
||||
if is_available(feature):
|
||||
result.update(warmed=True, action="cached")
|
||||
else:
|
||||
ensure(feature, prompt=False)
|
||||
result.update(warmed=True, action="installed")
|
||||
except Exception as exc:
|
||||
logger.debug("[TTS] SDK warm-up for %s skipped: %s", name, exc)
|
||||
result.update(action="error", error=str(exc))
|
||||
return result
|
||||
|
||||
|
||||
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>}``.
|
||||
"""
|
||||
name = (provider or "").lower().strip()
|
||||
if not name:
|
||||
tts_config = _origin()._load_tts_config()
|
||||
_signal_user_tts_provider(_origin()._get_provider(tts_config), tts_config, "release")
|
||||
released = 0
|
||||
for cache_name, cache in _LOCAL_TTS_MODEL_CACHES.items():
|
||||
if name and cache_name != name:
|
||||
continue
|
||||
released += len(cache)
|
||||
cache.clear()
|
||||
if released:
|
||||
logger.info("[TTS] released %d resident local model(s)", released)
|
||||
return {"released": released}
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
with _tts_lease_lock:
|
||||
_tts_leases.add(lease)
|
||||
holders = len(_tts_leases)
|
||||
result = _origin().warm_tts_provider(tts_config)
|
||||
result["leases"] = holders
|
||||
return result
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
with _tts_lease_lock:
|
||||
_tts_leases.discard(lease)
|
||||
holders = len(_tts_leases)
|
||||
result: Dict[str, Any] = {"leases": holders, "released": 0}
|
||||
if holders == 0:
|
||||
result["released"] = release_tts_provider()["released"]
|
||||
return result
|
||||
|
||||
|
||||
def tts_lease_holders() -> List[str]:
|
||||
"""Snapshot of live lease names (diagnostics / tests)."""
|
||||
with _tts_lease_lock:
|
||||
return sorted(_tts_leases)
|
||||
|
||||
|
||||
def _reset_tts_leases_for_tests() -> None:
|
||||
with _tts_lease_lock:
|
||||
_tts_leases.clear()
|
||||
|
||||
253
tools/tts_tool_openai.py
Normal file
253
tools/tts_tool_openai.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
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,
|
||||
)
|
||||
from tools.tts_tool_providers import _tts_response_format_from_path
|
||||
|
||||
logger = logging.getLogger("tools.tts_tool")
|
||||
|
||||
|
||||
def _origin():
|
||||
"""``tools.tts_tool``, resolved per call so monkeypatched seams there still apply."""
|
||||
from tools import tts_tool
|
||||
|
||||
return tts_tool
|
||||
|
||||
|
||||
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".
|
||||
MANAGED_OPENAI_TTS_MODELS = frozenset({"gpt-4o-mini-tts"})
|
||||
DEFAULT_OPENAI_VOICE = "alloy"
|
||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
# DeepInfra base URL is resolved via hermes_cli.models.deepinfra_base_url (shared).
|
||||
DEFAULT_DEEPINFRA_TTS_VOICE = "default"
|
||||
|
||||
|
||||
def _managed_openai_audio_route() -> Optional[tuple]:
|
||||
gateway = _origin().resolve_managed_tool_gateway("openai-audio")
|
||||
if gateway is None:
|
||||
return None
|
||||
return gateway.nous_user_token, urljoin(f"{gateway.gateway_origin.rstrip('/')}/", "v1"), True
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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")
|
||||
|
||||
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)",
|
||||
))
|
||||
return route
|
||||
|
||||
if cfg_api_key:
|
||||
return cfg_api_key, direct_base, False
|
||||
direct_api_key = _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",
|
||||
))
|
||||
|
||||
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",
|
||||
)
|
||||
)
|
||||
raise ValueError(message)
|
||||
return route
|
||||
|
||||
|
||||
def _has_openai_audio_backend() -> bool:
|
||||
"""Return True when the selected OpenAI audio route is usable."""
|
||||
try:
|
||||
_origin()._resolve_openai_audio_client_config()
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _generate_openai_tts(
|
||||
text: str,
|
||||
output_path: str,
|
||||
tts_config: Dict[str, Any],
|
||||
*,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
voice: Optional[str] = None,
|
||||
speed: Optional[float] = None,
|
||||
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.
|
||||
"""
|
||||
fallback_base: Optional[str] = None
|
||||
is_managed = False
|
||||
explicit_base_url = base_url is not None
|
||||
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 {}
|
||||
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.
|
||||
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
|
||||
):
|
||||
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 "
|
||||
"to use %r directly.",
|
||||
model, DEFAULT_OPENAI_MODEL, model,
|
||||
)
|
||||
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:
|
||||
create_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"voice": voice,
|
||||
"input": text,
|
||||
"response_format": response_format,
|
||||
"extra_headers": {"x-idempotency-key": str(uuid.uuid4())},
|
||||
}
|
||||
if speed != 1.0:
|
||||
create_kwargs["speed"] = max(0.25, min(4.0, speed))
|
||||
if instructions:
|
||||
create_kwargs["instructions"] = instructions
|
||||
if language:
|
||||
create_kwargs["extra_body"] = {"lang_code": language}
|
||||
response = client.audio.speech.create(**create_kwargs)
|
||||
|
||||
response.stream_to_file(output_path)
|
||||
return output_path
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
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 = {}
|
||||
|
||||
from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids
|
||||
|
||||
model = di_config.get("model")
|
||||
if not isinstance(model, str) or not model.strip():
|
||||
candidates = deepinfra_model_ids("tts")
|
||||
if not candidates:
|
||||
raise ValueError(
|
||||
"No DeepInfra TTS model available. Pin one in config.yaml "
|
||||
"under tts.deepinfra.model, or check connectivity to "
|
||||
"api.deepinfra.com so the live catalog can be fetched."
|
||||
)
|
||||
model = candidates[0]
|
||||
return _origin()._generate_openai_tts(
|
||||
text,
|
||||
output_path,
|
||||
tts_config,
|
||||
api_key=api_key,
|
||||
base_url=deepinfra_base_url(di_config),
|
||||
model=model,
|
||||
voice=di_config.get("voice", DEFAULT_DEEPINFRA_TTS_VOICE),
|
||||
speed=float(di_config.get("speed", tts_config.get("speed", 1.0))),
|
||||
)
|
||||
Reference in New Issue
Block a user