# NousResearch/hermes-agent · MiniMax TTS default model and endpoint updates

## Open now

- Nothing open on GitHub.

These issues and pull requests address outdated MiniMax TTS configuration defaults, specifically updating the model from speech-01 to speech-02 and correcting the API endpoint URL. The members appear to cover the same fix from multiple angles or iterations.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #25431. Verify it addresses your case.
- **Recommended:** read PR #25431 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #20869: fix(tts): MiniMax TTS defaults are outdated — model speech-01 and old API endpoint

## Prior & competing PRs

- **#25431: fix(tts): MiniMax TTS — switch defaults to live t2a_v2 endpoint**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `scripts/release.py`, `tests/tools/test_tts_speed.py`, `tools/tts_tool.py`
  - fix hunks:
  - `scripts/release.py`

    ```diff
    @@ -223,6 +223,7 @@
         "[redacted:email]": "htsh",
         "[redacted:email]": "pty819",
         "pty819@users.noreply.github.com": "pty819",
    +    "14341805+pty819@users.noreply.github.com": "pty819",
         "[redacted:email]": "chennest",
         # Curator fixes (Apr 30 2026)
         "[redacted:email]": "y0shua1ee",
    ```
  - `tests/tools/test_tts_speed.py`

    ```diff
    @@ -8,7 +8,12 @@
     
     @pytest.fixture(autouse=True)
     def clean_env(monkeypatch):
    -    for key in ("OPENAI_API_KEY", "MINIMAX_API_KEY", "HERMES_SESSION_PLATFORM"):
    +    for key in (
    +        "OPENAI_API_KEY",
    +        "MINIMAX_API_KEY",
    +        "MINIMAX_GROUP_ID",
    +        "HERMES_SESSION_PLATFORM",
    +    ):
             monkeypatch.delenv(key, raising=False)
     
     
    ```

    ```diff
    @@ -110,37 +115,126 @@ def test_speed_clamped_high(self, tmp_path, monkeypatch):
     
     
     # ---------------------------------------------------------------------------
    -# MiniMax TTS (new API: raw audio, no speed/voice_setting)
    +# MiniMax TTS (t2a_v2 endpoint: nested voice_setting/audio_setting,
    +# JSON response with hex-encoded audio.  Falls back to the legacy
    +# text_to_speech endpoint shape when the base_url points at it.)
     # ---------------------------------------------------------------------------
     
    -class TestMinimaxTtsSpeed:
    +
    +def _hex_response(payload_audio: bytes = b"\x00\x01\x02\x03"):
    +    """Build a mock response shaped like a successful t2a_v2 reply."""
    +    mock_response = MagicMock()
    +    mock_response.status_code = 200
    +    mock_response.headers = {"Content-Type": "application/json"}
    +    mock_response.json.return_value = {
    +        "data": {"audio": payload_audio.hex(), "status": 2},
    +        "base_resp": {"status_code": 0, "status_msg": "success"},
    +    }
    +    return mock_response
    +
    +
    +class TestMinimaxTtsT2aV2:
    +    """Default path: base_url contains 't2a_v2'."""
    +
    +    def _run(self, tts_config, tmp_path, monkeypatch, response=None):
    +        monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
    +        resp = response if response is not None else _hex_response()
    +        with patch("requests.post", return_value=resp) as mock_post:
    +            from tools.tts_tool import _generate_minimax_tts
    +            output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config)
    +        return mock_post, output
    +
    +    def test_nested_payload(self, tmp_path, monkeypatch):
    +        """Default endpoint uses nested voice_setting / audio_setting."""
    +        mock_post, _ = self._run({}, tmp_path, monkeypatch)
    +        payload = mock_post.call_args[1]["json"]
    +        assert payload["model"] == "speech-02-hd"
    +        assert payload["text"] == "Hello"
    +        assert "voice_setting" in payload
    +        assert payload["voice_setting"]["voice_id"] == "English_expressive_narrator"
    +        assert "audio_setting" in payload
    +        assert payload["audio_setting"]["format"] == "mp3"
    +        # Don't send flat top-level voice_id alongside nested voice_setting.
    +        assert "voice_id" not in payload
    +
    +    def test_decodes_hex_audio(self, tmp_path, monkeypatch):
    +        """t2a_v2 hex-encoded audio is decoded and written verbatim."""
    +        _, output = self._run({}, tmp_path, monkeypatch)
    +        with open(output, "rb") as f:
    +            assert f.read() == b"\x00\x01\x02\x03"
    +
    +    def test_default_url_is_t2a_v2(self, tmp_path, monkeypatch):
    +        """Default base URL points at the live t2a_v2 endpoint."""
    +        mock_post, _ = self._run({}, tmp_path, monkeypatch)
    +        url = mock_post.call_args[0][0]
    +        assert "t2a_v2" in url
    +        assert "api.minimax.io" in url
    +
    +    def test_group_id_from_config(self, tmp_path, monkeypatch):
    +        """group_id from config attaches as ?GroupId=<id>."""
    +        mock_post, _ = self._run({"minimax": {"group_id": "G123"}}, tmp_path, monkeypatch)
    +        url = mock_post.call_args[0][0]
    +        assert "GroupId=G123" in url
    +
    +    def test_group_id_from_env(self, tmp_path, monkeypatch):
    +        """MINIMAX_GROUP_ID env var attaches as ?GroupId=<id>."""
    +        monkeypatch.setenv("MINIMAX_GROUP_ID", "G456")
    +        mock_post, _ = self._run({}, tmp_path, monkeypatch)
    +        url = mock_post.call_args[0][0]
    +        assert "GroupId=G456" in url
    +
    +    def test_group_id_already_in_url_left_alone(self, tmp_path, monkeypatch):
    +        """If user already set GroupId in base_url, don't double-append it."""
    +        cfg = {"minimax": {
    +            "base_url": "https://api.minimax.io/v1/t2a_v2?GroupId=PRESET",
    +            "group_id": "IGNORED",
    +        }}
    +        mock_post, _ = self._run(cfg, tmp_path, monkeypatch)
    +        url = mock_post.call_args[0][0]
    +        assert url.count("GroupId=") == 1
    +        assert "GroupId=PRESET" in url
    +
    +    def test_api_error_raises(self, tmp_path, monkeypatch):
    +        """Non-zero base_resp.status_code surfaces as RuntimeError."""
    +        resp = MagicMock()
    +        resp.status_code = 200
    +        resp.headers = {"Content-Type": "application/json"}
    +        resp.json.return_value = {
    +            "data": {"audio": "", "status": 1},
    +            "base_resp": {"status_code": 2013, "status_msg": "invalid voice"},
    +        }
    +        with pytest.raises(RuntimeError, match="2013"):
    +            self._run({}, tmp_path, monkeypatch, response=resp)
    +
    +
    +class TestMinimaxTtsLegacyTextToSpeech:
    +    """Legacy path: caller pins base_url to the old text_to_speech endpoint."""
    +
    +    LEGACY_URL = "https://api.minimax.chat/v1/text_to_speech"
    +
         def _run(self, tts_config, tmp_path, monkeypatch):
             monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
    +        cfg = dict(tts_config)
    +        cfg.setdefault("minimax", {})["base_url"] = self.LEGACY_URL
             mock_response = MagicMock()
             mock_response.status_code = 200
             mock_response.headers = {"Content-Type": "audio/mpeg"}
             mock_response.content = b"\x00\x01\x02\x03"
    -
    -        # requests is imported locally inside _generate_minimax_tts
             with patch("requests.post", return_value=mock_response) as mock_post:
                 from tools.tts_tool import _generate_minimax_tts
    -            output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config)
    +            output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), cfg)
             return mock_post, output
     
    -    def test_simple_payload(self, tmp_path, monkeypatch):
    -        """New API uses flat payload with model, text, voice_id."""
    +    def test_flat_payload(self, tmp_path, monkeypatch):
    +        """Legacy endpoint keeps the flat {model, text, voice_id} shape."""
             mock_post, _ = self._run({}, tmp_path, monkeypatch)
             payload = mock_post.call_args[1]["json"]
    -        assert "model" in payload
    -        assert "text" in payload
             assert "voice_id" in payload
             assert "voice_setting" not in payload
             assert "audio_setting" not in payload
    -        assert "stream" not in payload
     
         def test_writes_raw_audio(self, tmp_path, monkeypatch):
    -        """New API returns raw bytes written directly to file."""
    +        """Legacy endpoint returns raw bytes written directly to file."""
             _, output = self._run({}, tmp_path, monkeypatch)
    -        assert output == str(tmp_path / "out.mp3")
             with open(output, "rb") as f:
                 assert f.read() == b"\x00\x01\x02\x03"
    ```
  - `tools/tts_tool.py`

    ```diff
    @@ -159,9 +159,9 @@ def _import_piper():
     DEFAULT_PIPER_VOICE = "en_US-lessac-medium"  # balanced size/quality
     DEFAULT_OPENAI_VOICE = "alloy"
     DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
    -DEFAULT_MINIMAX_MODEL = "speech-01"
    -DEFAULT_MINIMAX_VOICE_ID = "female-shaonv"
    -DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.chat/v1/text_to_speech"
    +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_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603"
     DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8"  # Paul - Neutral
     DEFAULT_XAI_VOICE_ID = "eve"
    ```

    ```diff
    @@ -960,11 +960,11 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
     # ===========================================================================
     def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
         """
    -    Generate audio using MiniMax TTS API (v1/text_to_speech).
    +    Generate audio using MiniMax TTS API.
     
    -    The current API (api.minimax.chat/v1/text_to_speech) uses a simple payload
    -    and returns raw audio bytes directly (Content-Type: audio/mpeg), unlike
    -    the deprecated v1/t2a_v2 endpoint which returned JSON with hex-encoded audio.
    +    Supports two endpoints:
    +    - v1/text_to_speech: simple payload, returns raw audio (Content-Type: audio/mpeg)
    +    - v1/t2a_v2: nested voice_setting/audio_setting, returns JSON with hex-encoded audio
     
         Args:
             text: Text to convert (max 10,000 characters).
    ```

    ```diff
    @@ -984,56 +984,106 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
         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)
    -
    -    payload = {
    -        "model": model,
    -        "text": text,
    -        "voice_id": voice_id,
    -    }
    +    speed = mm_config.get("speed", 1.0)
    +    vol = mm_config.get("vol", 1.0)
    +    pitch = mm_config.get("pitch", 0)
    +    emotion = mm_config.get("emotion", "neutral")
    +    sample_rate = mm_config.get("sample_rate", 32000)
    +    bitrate = mm_config.get("bitrate", 128000)
    +
    +    # MiniMax accounts scope TTS requests by GroupId.  When present, the docs
    +    # show it as a ?GroupId=<id> query param on the t2a_v2 URL.  Accept it
    +    # from config or from the MINIMAX_GROUP_ID env var; only attach when the
    +    # URL doesn't already carry one.
    +    group_id = (
    +        str(mm_config.get("group_id") or "").strip()
    +        or (get_env_value("MINIMAX_GROUP_ID") or "").strip()
    +    )
    +    if group_id and "GroupId=" not in base_url:
    +        sep = "&" if "?" in base_url else "?"
    +        base_url = f"{base_url}{sep}GroupId={group_id}"
     
         headers = {
             "Content-Type": "application/json",
             "Authorization": f"Bearer {api_key}",
         }
     
    -    response = requests.post(base_url, json=payload, headers=headers, timeout=60)
    +    # Detect endpoint from URL
    +    is_t2a_v2 = "t2a_v2" in base_url
     
    -    content_type = response.headers.get("Content-Type", "")
    +    if is_t2a_v2:
    +        # t2a_v2 endpoint: nested voice_setting/audio_setting structure
    +        payload = {
    +            "model": model,
    +            "text": text,
    +            "voice_setting": {
    +                "voice_id": voice_id,
    +                "speed": speed,
    +                "vol": vol,
    +                "pitch": pitch,
    +                "emotion": emotion,
    +            },
    +            "audio_setting": {
    +                "sample_rate": sample_rate,
    +                "bitrate": bitrate,
    +                "format": "mp3",
    +                "channel": 1,
    +            },
    +        }
    +    else:
    +        # text_to_speech endpoint: flat payload
    +        payload = {
    +            "model": model,
    +            "text": text,
    +            "voice_id": voice_id,
    +        }
     
    -    if "audio/" in content_type:
    -        # New API: returns raw audio directly
    -        with open(output_path, "wb") as f:
    -            f.write(response.content)
    -        return output_path
    +    response = requests.post(base_url, json=payload, headers=headers, timeout=60)
     
    -    # Legacy / fallback: try parsing as JSON with hex-encoded audio
    -    try:
    +    if is_t2a_v2:
    +        # t2a_v2 returns JSON with hex-encoded audio
             result = response.json()
    -    except Exception:
    -        response.raise_for_status()
    -        raise RuntimeError(
    -            f"MiniMax TTS returned unexpected Content-Type '{content_type}' "
    -            f"({len(response.content)} bytes)"
    -        )
    +        base_resp = result.get("base_resp", {})
    +        status_code = base_resp.get("status_code", -1)
     
    -    base_resp = result.get("base_resp", {})
    -    status_code = base_resp.get("status_code", -1)
    +        if status_code != 0:
    +            status_msg = base_resp.get("status_msg", "unknown error")
    +            raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}")
     
    -    if status_code != 0:
    -        status_msg = base_resp.get("status_msg", "unknown error")
    -        raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}")
    +        hex_audio = result.get("data", {}).get("audio", "")
    +        if not hex_audio:
    +            raise RuntimeError("MiniMax TTS returned empty audio data")
     
    -    hex_audio = result.get("data", {}).get("audio", "")
    -    if not hex_audio:
    -        raise RuntimeError("MiniMax TTS returned empty audio data")
    +        audio_bytes = bytes.fromhex(hex_audio)
    +        with open(output_path, "wb") as f:
    +            f.write(audio_bytes)
    +        return output_path
     
    -    # Legacy: hex-encoded audio
    -    audio_bytes = bytes.fromhex(hex_audio)
    +    else:
    +        # text_to_speech returns raw audio directly
    +        content_type = response.headers.get("Content-Type", "")
     
    -    with open(output_path, "wb") as f:
    -        f.write(audio_bytes)
    +        if "audio/" in content_type:
    +            with open(output_path, "wb") as f:
    +                f.write(response.content)
    +            return output_path
     
    -    return output_path
    +        # Fallback: try parsing as JSON
    +        try:
    +            result = response.json()
    +            base_resp = result.get("base_resp", {})
    +            status_code = base_resp.get("status_code", -1)
    +            if status_code != 0:
    +                status_msg = base_resp.get("status_msg", "unknown error")
    +                raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}")
    +        except Exception:
    +            response.raise_for_status()
    +            raise RuntimeError(
    +                f"MiniMax TTS returned unexpected Content-Type '{content_type}' "
    +                f"({len(response.content)} bytes)"
    +            )
    +
    +        raise RuntimeError("MiniMax TTS returned no audio data")
     
     
     # ===========================================================================
    ```
- **#20870: fix(tts): update MiniMax TTS default model to speech-02 and correct API endpoint**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `c875c0dc1`, `7f08cb594`, `#25431`
- **#20933: fix(tts): MiniMax TTS defaults — speech-02 + api.minimaxi.com endpoint**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#20870`

## Fix status

- Merged to main in PR #25431 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-14`.
- Subsystem: `skills`
