NousResearch/hermes-agent

MiniMax TTS default model and endpoint updates

Cluster 20869 · skills. Data as of

Nothing open on GitHub. Fix merged in #25431 on 14 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #25431. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #25431 fix(tts): MiniMax TTS — switch defaults to live t2a_v2 endpoint

    merged to main

    files: scripts/release.py, tests/tools/test_tts_speed.py, tools/tts_tool.py

    Show the diff
    • scripts/release.py
      @@ -223,6 +223,7 @@
           "[redacted:email]": "htsh",
           "[redacted:email]": "pty819",
           "[email protected]": "pty819",
      +    "[email protected]": "pty819",
           "[redacted:email]": "chennest",
           # Curator fixes (Apr 30 2026)
           "[redacted:email]": "y0shua1ee",
    • tests/tools/test_tts_speed.py
      @@ -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)
       
       
      @@ -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
      @@ -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"
      @@ -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).
      @@ -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")
       
       
       # ===========================================================================

2 PRs closed without merging

From the close comments: duplicate 1 · merged via another change 1

  • #20870 fix(tts): update MiniMax TTS default model to speech-02 and correct API endpoint · merged via another change → c875c0d, 7f08cb5, #25431
  • #20933 fix(tts): MiniMax TTS defaults — speech-02 + api.minimaxi.com endpoint · duplicate → #20870
Closed issues · 1
  • Closed#20869fix(tts): MiniMax TTS defaults are outdated — model speech-01 and old API endpoint
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
skills
Merged PRs
#25431
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
14 May 2026

For your agent

The same facts, typed.