mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(tts): bind MiniMax credentials to their region endpoint
This commit is contained in:
parent
7b0a575c82
commit
45f7030786
3 changed files with 391 additions and 10 deletions
276
tests/tools/test_tts_minimax_region.py
Normal file
276
tests/tools/test_tts_minimax_region.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""MiniMax TTS region, endpoint, and credential selection tests."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.tts_tool import (
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
DEFAULT_MINIMAX_CN_BASE_URL,
|
||||
_generate_minimax_tts,
|
||||
_resolve_minimax_tts_runtime,
|
||||
check_tts_requirements,
|
||||
)
|
||||
|
||||
|
||||
GLOBAL_CREDENTIAL_SENTINEL = "FAKE_GLOBAL_CREDENTIAL"
|
||||
CN_CREDENTIAL_SENTINEL = "FAKE_CN_CREDENTIAL"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_minimax_credentials(monkeypatch):
|
||||
values = {}
|
||||
monkeypatch.setattr(
|
||||
"tools.tts_tool.get_env_value",
|
||||
lambda name, default=None: values.get(name, default),
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "credentials", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{},
|
||||
{"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL},
|
||||
(
|
||||
"global",
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
"MINIMAX_API_KEY",
|
||||
GLOBAL_CREDENTIAL_SENTINEL,
|
||||
),
|
||||
id="global-only",
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
{"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL},
|
||||
(
|
||||
"cn",
|
||||
DEFAULT_MINIMAX_CN_BASE_URL,
|
||||
"MINIMAX_CN_API_KEY",
|
||||
CN_CREDENTIAL_SENTINEL,
|
||||
),
|
||||
id="china-only",
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
{
|
||||
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
|
||||
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
(
|
||||
"global",
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
"MINIMAX_API_KEY",
|
||||
GLOBAL_CREDENTIAL_SENTINEL,
|
||||
),
|
||||
id="both-default-to-global",
|
||||
),
|
||||
pytest.param(
|
||||
{"minimax": {"region": "global"}},
|
||||
{
|
||||
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
|
||||
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
(
|
||||
"global",
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
"MINIMAX_API_KEY",
|
||||
GLOBAL_CREDENTIAL_SENTINEL,
|
||||
),
|
||||
id="explicit-global",
|
||||
),
|
||||
pytest.param(
|
||||
{"minimax": {"region": "cn"}},
|
||||
{
|
||||
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
|
||||
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
(
|
||||
"cn",
|
||||
DEFAULT_MINIMAX_CN_BASE_URL,
|
||||
"MINIMAX_CN_API_KEY",
|
||||
CN_CREDENTIAL_SENTINEL,
|
||||
),
|
||||
id="explicit-china",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_runtime_selection_matrix(
|
||||
_fake_minimax_credentials,
|
||||
config,
|
||||
credentials,
|
||||
expected,
|
||||
):
|
||||
_fake_minimax_credentials.update(credentials)
|
||||
|
||||
runtime = _resolve_minimax_tts_runtime(config)
|
||||
|
||||
assert (
|
||||
runtime.region,
|
||||
runtime.endpoint,
|
||||
runtime.credential_source,
|
||||
runtime.api_key,
|
||||
) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("region", "credentials", "missing_source"),
|
||||
[
|
||||
pytest.param(
|
||||
"global",
|
||||
{"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL},
|
||||
"MINIMAX_API_KEY",
|
||||
id="global-does-not-borrow-china-key",
|
||||
),
|
||||
pytest.param(
|
||||
"cn",
|
||||
{"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL},
|
||||
"MINIMAX_CN_API_KEY",
|
||||
id="china-does-not-borrow-global-key",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_explicit_region_requires_matching_credential(
|
||||
_fake_minimax_credentials,
|
||||
region,
|
||||
credentials,
|
||||
missing_source,
|
||||
):
|
||||
_fake_minimax_credentials.update(credentials)
|
||||
|
||||
with pytest.raises(ValueError, match=missing_source):
|
||||
_resolve_minimax_tts_runtime({"minimax": {"region": region}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("region", "base_url"),
|
||||
[
|
||||
pytest.param(
|
||||
"global",
|
||||
DEFAULT_MINIMAX_CN_BASE_URL,
|
||||
id="global-key-china-endpoint",
|
||||
),
|
||||
pytest.param(
|
||||
"cn",
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
id="china-key-global-endpoint",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_official_cross_region_endpoint_is_rejected(
|
||||
_fake_minimax_credentials,
|
||||
region,
|
||||
base_url,
|
||||
):
|
||||
_fake_minimax_credentials.update(
|
||||
{
|
||||
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
|
||||
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="points to the .* MiniMax endpoint"):
|
||||
_resolve_minimax_tts_runtime(
|
||||
{"minimax": {"region": region, "base_url": base_url}}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("region", "expected_url", "expected_key"),
|
||||
[
|
||||
pytest.param(
|
||||
"global",
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
GLOBAL_CREDENTIAL_SENTINEL,
|
||||
id="global-pair",
|
||||
),
|
||||
pytest.param(
|
||||
"cn",
|
||||
DEFAULT_MINIMAX_CN_BASE_URL,
|
||||
CN_CREDENTIAL_SENTINEL,
|
||||
id="china-pair",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_generate_uses_one_region_bound_endpoint_and_header(
|
||||
tmp_path,
|
||||
_fake_minimax_credentials,
|
||||
region,
|
||||
expected_url,
|
||||
expected_key,
|
||||
):
|
||||
_fake_minimax_credentials.update(
|
||||
{
|
||||
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
|
||||
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
|
||||
}
|
||||
)
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
"base_resp": {"status_code": 0},
|
||||
"data": {"audio": "0001"},
|
||||
}
|
||||
output = tmp_path / f"{region}.mp3"
|
||||
|
||||
with patch("requests.post", return_value=response) as post:
|
||||
result = _generate_minimax_tts(
|
||||
"hello",
|
||||
str(output),
|
||||
{"minimax": {"region": region}},
|
||||
)
|
||||
|
||||
assert result == str(output)
|
||||
assert output.read_bytes() == b"\x00\x01"
|
||||
assert post.call_args.args == (expected_url,)
|
||||
assert (
|
||||
post.call_args.kwargs["headers"]["Authorization"]
|
||||
== f"Bearer {expected_key}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "credentials", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{"provider": "minimax"},
|
||||
{"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL},
|
||||
True,
|
||||
id="china-only-available",
|
||||
),
|
||||
pytest.param(
|
||||
{"provider": "minimax", "minimax": {"region": "cn"}},
|
||||
{"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL},
|
||||
False,
|
||||
id="selected-region-missing",
|
||||
),
|
||||
pytest.param(
|
||||
{"provider": "minimax", "minimax": {"region": "invalid"}},
|
||||
{
|
||||
"MINIMAX_API_KEY": GLOBAL_CREDENTIAL_SENTINEL,
|
||||
"MINIMAX_CN_API_KEY": CN_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
False,
|
||||
id="invalid-region",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_availability_uses_atomic_runtime(
|
||||
monkeypatch,
|
||||
_fake_minimax_credentials,
|
||||
config,
|
||||
credentials,
|
||||
expected,
|
||||
):
|
||||
_fake_minimax_credentials.update(credentials)
|
||||
monkeypatch.setattr("tools.tts_tool._load_tts_config", lambda: config)
|
||||
|
||||
assert check_tts_requirements() is expected
|
||||
|
||||
|
||||
def test_runtime_repr_excludes_raw_credential(_fake_minimax_credentials):
|
||||
_fake_minimax_credentials["MINIMAX_API_KEY"] = GLOBAL_CREDENTIAL_SENTINEL
|
||||
|
||||
runtime = _resolve_minimax_tts_runtime({})
|
||||
|
||||
assert GLOBAL_CREDENTIAL_SENTINEL not in repr(runtime)
|
||||
|
|
@ -6,7 +6,7 @@ Built-in TTS providers:
|
|||
- Edge TTS (default, free, no API key): Microsoft Edge neural voices
|
||||
- ElevenLabs (premium): High-quality voices, needs ELEVENLABS_API_KEY
|
||||
- OpenAI TTS: Good quality, needs OPENAI_API_KEY
|
||||
- MiniMax TTS: High-quality with voice cloning, needs MINIMAX_API_KEY
|
||||
- MiniMax TTS: High-quality with voice cloning, needs the selected region's key
|
||||
- Mistral (Voxtral TTS): Multilingual, native Opus, needs MISTRAL_API_KEY
|
||||
- Google Gemini TTS: Controllable, 30 prebuilt voices, needs GEMINI_API_KEY
|
||||
- xAI TTS: Grok voices, uses xAI Grok OAuth credentials or XAI_API_KEY
|
||||
|
|
@ -49,6 +49,7 @@ import subprocess
|
|||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Any, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
|
@ -221,6 +222,7 @@ DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
|||
DEFAULT_MINIMAX_MODEL = "speech-02-hd"
|
||||
DEFAULT_MINIMAX_VOICE_ID = "English_expressive_narrator"
|
||||
DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2"
|
||||
DEFAULT_MINIMAX_CN_BASE_URL = "https://api.minimaxi.com/v1/t2a_v2"
|
||||
DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603"
|
||||
DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral
|
||||
DEFAULT_XAI_VOICE_ID = "eve"
|
||||
|
|
@ -491,6 +493,88 @@ def _get_provider(tts_config: Dict[str, Any]) -> str:
|
|||
return (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _MiniMaxTTSRuntime:
|
||||
"""A region-bound MiniMax endpoint and credential.
|
||||
|
||||
The credential is excluded from ``repr`` so diagnostics cannot expose it
|
||||
accidentally.
|
||||
"""
|
||||
|
||||
region: str
|
||||
endpoint: str
|
||||
credential_source: str
|
||||
api_key: str = field(repr=False)
|
||||
|
||||
|
||||
def _resolve_minimax_tts_runtime(
|
||||
tts_config: Dict[str, Any],
|
||||
) -> _MiniMaxTTSRuntime:
|
||||
"""Select MiniMax TTS region, endpoint, and credential atomically.
|
||||
|
||||
An explicit ``tts.minimax.region`` wins. Without one, the legacy global
|
||||
credential wins when present; a China credential is selected only when it
|
||||
is the sole configured MiniMax credential.
|
||||
"""
|
||||
mm_config = tts_config.get("minimax", {})
|
||||
if not isinstance(mm_config, dict):
|
||||
mm_config = {}
|
||||
|
||||
credentials = {
|
||||
"global": (
|
||||
"MINIMAX_API_KEY",
|
||||
str(_resolve_provider_key("MINIMAX_API_KEY", "minimax") or "").strip(),
|
||||
),
|
||||
"cn": (
|
||||
"MINIMAX_CN_API_KEY",
|
||||
str(_resolve_provider_key("MINIMAX_CN_API_KEY", "minimax") or "").strip(),
|
||||
),
|
||||
}
|
||||
endpoints = {
|
||||
"global": DEFAULT_MINIMAX_BASE_URL,
|
||||
"cn": DEFAULT_MINIMAX_CN_BASE_URL,
|
||||
}
|
||||
|
||||
configured_region = str(mm_config.get("region") or "").strip().lower()
|
||||
if configured_region and configured_region not in endpoints:
|
||||
raise ValueError("tts.minimax.region must be 'global' or 'cn'")
|
||||
|
||||
if configured_region:
|
||||
region = configured_region
|
||||
elif credentials["global"][1]:
|
||||
region = "global"
|
||||
elif credentials["cn"][1]:
|
||||
region = "cn"
|
||||
else:
|
||||
region = "global"
|
||||
|
||||
credential_source, api_key = credentials[region]
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"{credential_source} not set for MiniMax TTS region {region!r}"
|
||||
)
|
||||
|
||||
endpoint = str(mm_config.get("base_url") or endpoints[region]).strip()
|
||||
endpoint_host = (urlparse(endpoint).hostname or "").lower()
|
||||
official_region_hosts = {
|
||||
"global": frozenset({"api.minimax.io", "api.minimax.chat"}),
|
||||
"cn": frozenset({"api.minimaxi.com"}),
|
||||
}
|
||||
other_region = "cn" if region == "global" else "global"
|
||||
if endpoint_host in official_region_hosts[other_region]:
|
||||
raise ValueError(
|
||||
f"tts.minimax.base_url points to the {other_region!r} MiniMax endpoint "
|
||||
f"but region is {region!r}"
|
||||
)
|
||||
|
||||
return _MiniMaxTTSRuntime(
|
||||
region=region,
|
||||
endpoint=endpoint,
|
||||
credential_source=credential_source,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Custom command providers (type: command under tts.providers.<name>)
|
||||
# ===========================================================================
|
||||
|
|
@ -1695,14 +1779,14 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
|
|||
"""
|
||||
import requests
|
||||
|
||||
api_key = (_resolve_provider_key("MINIMAX_API_KEY", "minimax") or "")
|
||||
if not api_key:
|
||||
raise ValueError("MINIMAX_API_KEY not set. Get one at https://platform.minimax.io/")
|
||||
runtime = _resolve_minimax_tts_runtime(tts_config)
|
||||
|
||||
mm_config = tts_config.get("minimax", {})
|
||||
if not isinstance(mm_config, dict):
|
||||
mm_config = {}
|
||||
model = mm_config.get("model", DEFAULT_MINIMAX_MODEL)
|
||||
voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID)
|
||||
base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL)
|
||||
base_url = runtime.endpoint
|
||||
speed = mm_config.get("speed", 1.0)
|
||||
vol = mm_config.get("vol", 1.0)
|
||||
pitch = mm_config.get("pitch", 0)
|
||||
|
|
@ -1724,7 +1808,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
|
|||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Authorization": f"Bearer {runtime.api_key}",
|
||||
}
|
||||
|
||||
# Detect endpoint from URL
|
||||
|
|
@ -3000,7 +3084,11 @@ def check_tts_requirements() -> bool:
|
|||
return False
|
||||
return bool(_resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra"))
|
||||
if provider == "minimax":
|
||||
return bool(_resolve_provider_key("MINIMAX_API_KEY", "minimax"))
|
||||
try:
|
||||
_resolve_minimax_tts_runtime(tts_config)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
if provider == "xai":
|
||||
try:
|
||||
from tools.xai_http import resolve_xai_http_credentials
|
||||
|
|
@ -3368,12 +3456,20 @@ if __name__ == "__main__":
|
|||
" API Key: "
|
||||
f"{'set' if resolve_openai_audio_api_key() else 'not set (VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY)'}"
|
||||
)
|
||||
print(f" MiniMax: {'API key set' if get_env_value('MINIMAX_API_KEY') else 'not set (MINIMAX_API_KEY)'}")
|
||||
config = _load_tts_config()
|
||||
try:
|
||||
minimax_runtime = _resolve_minimax_tts_runtime(config)
|
||||
minimax_status = (
|
||||
f"API key set ({minimax_runtime.region}, "
|
||||
f"{minimax_runtime.credential_source})"
|
||||
)
|
||||
except ValueError as exc:
|
||||
minimax_status = f"unavailable ({exc})"
|
||||
print(f" MiniMax: {minimax_status}")
|
||||
print(f" Piper: {'installed' if _check_piper_available() else 'not installed (pip install piper-tts)'}")
|
||||
print(f" ffmpeg: {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}")
|
||||
print(f"\n Output dir: {DEFAULT_OUTPUT_DIR}")
|
||||
|
||||
config = _load_tts_config()
|
||||
provider = _get_provider(config)
|
||||
print(f" Configured provider: {provider}")
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Convert text to speech with ten providers:
|
|||
| **Edge TTS** (default) | Good | Free | None needed |
|
||||
| **ElevenLabs** | Excellent | Paid | `ELEVENLABS_API_KEY` |
|
||||
| **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` |
|
||||
| **MiniMax TTS** | Excellent | Paid | `MINIMAX_API_KEY` |
|
||||
| **MiniMax TTS** | Excellent | Paid | `MINIMAX_API_KEY` or `MINIMAX_CN_API_KEY` |
|
||||
| **Mistral (Voxtral TTS)** | Excellent | Paid | `MISTRAL_API_KEY` |
|
||||
| **Google Gemini TTS** | Excellent | Free tier | `GEMINI_API_KEY` |
|
||||
| **xAI TTS** | Excellent | Paid | `XAI_API_KEY` |
|
||||
|
|
@ -58,11 +58,13 @@ tts:
|
|||
speed: 1.0 # 0.25 - 4.0
|
||||
# language: "es" # Sent as lang_code — only for OpenAI-compatible endpoints that support it (e.g. Kokoro)
|
||||
minimax:
|
||||
region: "global" # "global" or "cn"; see selection rules below
|
||||
model: "speech-02-hd" # speech-02-hd (default), speech-02-turbo
|
||||
voice_id: "English_Graceful_Lady" # See https://platform.minimax.io/faq/system-voice-id
|
||||
speed: 1 # 0.5 - 2.0
|
||||
vol: 1 # 0 - 10
|
||||
pitch: 0 # -12 - 12
|
||||
# base_url: "https://tts.example/v1/t2a_v2" # Optional endpoint override for the selected region
|
||||
mistral:
|
||||
model: "voxtral-mini-tts-2603"
|
||||
voice_id: "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral (default)
|
||||
|
|
@ -102,6 +104,13 @@ tts:
|
|||
# normalize_audio: true
|
||||
```
|
||||
|
||||
MiniMax TTS selects its region, endpoint, and credential together:
|
||||
|
||||
- `region: "global"` uses `https://api.minimax.io/v1/t2a_v2` with `MINIMAX_API_KEY`.
|
||||
- `region: "cn"` uses `https://api.minimaxi.com/v1/t2a_v2` with `MINIMAX_CN_API_KEY`.
|
||||
- If `region` is omitted, `MINIMAX_API_KEY` keeps precedence for backward compatibility. If only `MINIMAX_CN_API_KEY` is configured, Hermes selects `cn`.
|
||||
- An explicitly selected region must have its matching credential. Hermes never borrows the other region's key. A `base_url` override does not change the selected credential, and an override pointing at the other region's official endpoint is rejected.
|
||||
|
||||
**Speed control**: The global `tts.speed` value applies to all providers by default. Each provider can override it with its own `speed` setting (e.g., `tts.openai.speed: 1.5`). Provider-specific speed takes precedence over the global value. Default is `1.0` (normal speed).
|
||||
|
||||
### Gemini Persona Prompts
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue