254 lines
9.6 KiB
Python
254 lines
9.6 KiB
Python
"""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))),
|
|
)
|