Files
hermes-agent/tests/tools/test_tts_mistral.py
Teknium 3a2c03061c fix(stt,tts): restore mistralai — 2.4.8 is clean, ban lifted (#34841)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix(stt,tts): restore mistralai — 2.4.8 is clean, ban lifted

PyPI quarantined mistralai on 2026-05-12 after the malicious 2.4.6
release (Mini Shai-Hulud worm). 2.4.6 has since been removed from the
registry and clean releases resumed (2.4.7 2026-05-25, 2.4.8 2026-05-28).
This rolls back the blanket runtime ban so Voxtral STT + TTS work again,
following the restoration checklist the repo left in pyproject.toml.

Verified against the real SDK: 2.4.8 keeps the import path the code uses
(from mistralai.client import Mistral) and the audio.transcriptions.complete
/ audio.speech.complete surfaces.

Changes:
- pyproject.toml: re-add mistral extra pinned to mistralai==2.4.8; left
  OUT of [all] per the 2026-05-12 lazy-install policy (one quarantined
  release must not break fresh installs). uv.lock regenerated.
- tools/lazy_deps.py: add stt.mistral / tts.mistral entries so the SDK
  lazy-installs on first use (matches edge / elevenlabs).
- tools/transcription_tools.py: restore explicit-provider gate
  (_HAS_MISTRAL + key) and auto-detect entry (local>groq>openai>mistral>xai);
  _transcribe_mistral lazy-installs before import.
- tools/tts_tool.py: dispatcher routes back to _generate_mistral_tts;
  _import_mistral_client lazy-installs the SDK.
- hermes_cli/tools_config.py, hermes_cli/web_server.py: un-hide Mistral
  from the TTS provider picker and dashboard STT options.
- hermes_cli/security_advisories.py: KEEP the shai-hulud-2026-05 advisory
  (module policy forbids removal) — it is scoped to 2.4.6 only, so it
  still warns anyone with the poisoned build cached and never fires on
  2.4.8. Summary note updated to reflect the un-quarantine.
- tests: revert the disabled-behavior assertions added by the ban commit
  back to routing/positive expectations; add mistral to the
  lazy-installable-extras-excluded-from-[all] contract.

Reported by @SkYNewZ (#34503).

Validation: 189 targeted STT/TTS/lazy_deps/metadata tests pass; E2E with
the real mistralai 2.4.8 SDK routes both STT and TTS to mistral.
2026-05-29 13:24:12 -07:00

224 lines
9.2 KiB
Python

"""Tests for the Mistral (Voxtral) TTS provider in tools/tts_tool.py."""
import base64
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in ("MISTRAL_API_KEY", "HERMES_SESSION_PLATFORM"):
monkeypatch.delenv(key, raising=False)
@pytest.fixture
def mock_mistral_module():
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_mistral_cls = MagicMock(return_value=mock_client)
fake_module = MagicMock()
fake_module.Mistral = mock_mistral_cls
with patch.dict("sys.modules", {"mistralai": fake_module, "mistralai.client": fake_module}):
yield mock_client
class TestGenerateMistralTts:
def test_missing_api_key_raises_value_error(self, tmp_path, mock_mistral_module):
from tools.tts_tool import _generate_mistral_tts
output_path = str(tmp_path / "test.mp3")
with pytest.raises(ValueError, match="MISTRAL_API_KEY"):
_generate_mistral_tts("Hello", output_path, {})
def test_successful_generation(self, tmp_path, mock_mistral_module, monkeypatch):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
audio_content = b"fake-audio-bytes"
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(audio_content).decode()
)
output_path = str(tmp_path / "test.mp3")
result = _generate_mistral_tts("Hello world", output_path, {})
assert result == output_path
assert (tmp_path / "test.mp3").read_bytes() == audio_content
mock_mistral_module.audio.speech.complete.assert_called_once()
mock_mistral_module.__exit__.assert_called_once()
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["input"] == "Hello world"
assert call_kwargs["response_format"] == "mp3"
@pytest.mark.parametrize(
"extension, expected_format",
[(".ogg", "opus"), (".wav", "wav"), (".flac", "flac"), (".mp3", "mp3")],
)
def test_response_format_from_extension(
self, tmp_path, mock_mistral_module, monkeypatch, extension, expected_format
):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
output_path = str(tmp_path / f"test{extension}")
_generate_mistral_tts("Hi", output_path, {})
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["response_format"] == expected_format
def test_voice_id_passed_when_configured(
self, tmp_path, mock_mistral_module, monkeypatch
):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
config = {"mistral": {"voice_id": "my-voice-uuid"}}
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), config)
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["voice_id"] == "my-voice-uuid"
def test_default_voice_id_when_absent(
self, tmp_path, mock_mistral_module, monkeypatch
):
from tools.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), {})
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["voice_id"] == DEFAULT_MISTRAL_TTS_VOICE_ID
def test_default_voice_id_when_empty_string(
self, tmp_path, mock_mistral_module, monkeypatch
):
from tools.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
config = {"mistral": {"voice_id": ""}}
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), config)
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["voice_id"] == DEFAULT_MISTRAL_TTS_VOICE_ID
def test_api_error_sanitized(self, tmp_path, mock_mistral_module, monkeypatch):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.side_effect = RuntimeError(
"secret-key-in-error"
)
with pytest.raises(RuntimeError, match="RuntimeError") as exc_info:
_generate_mistral_tts("Hello", str(tmp_path / "test.mp3"), {})
assert "secret-key-in-error" not in str(exc_info.value)
def test_default_model_used(self, tmp_path, mock_mistral_module, monkeypatch):
from tools.tts_tool import DEFAULT_MISTRAL_TTS_MODEL, _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), {})
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["model"] == DEFAULT_MISTRAL_TTS_MODEL
def test_model_from_config_overrides_default(
self, tmp_path, mock_mistral_module, monkeypatch
):
from tools.tts_tool import _generate_mistral_tts
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"data").decode()
)
config = {"mistral": {"model": "voxtral-large-tts-9999"}}
_generate_mistral_tts("Hi", str(tmp_path / "test.mp3"), config)
call_kwargs = mock_mistral_module.audio.speech.complete.call_args[1]
assert call_kwargs["model"] == "voxtral-large-tts-9999"
class TestTtsDispatcherMistral:
def test_dispatcher_routes_to_mistral(
self, tmp_path, mock_mistral_module, monkeypatch
):
import json
from tools.tts_tool import text_to_speech_tool
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
audio_data=base64.b64encode(b"audio").decode()
)
output_path = str(tmp_path / "out.mp3")
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
result = json.loads(text_to_speech_tool("Hello", output_path=output_path))
assert result["success"] is True
assert result["provider"] == "mistral"
mock_mistral_module.audio.speech.complete.assert_called_once()
def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch):
import json
from tools.tts_tool import text_to_speech_tool
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
with patch(
"tools.tts_tool._import_mistral_client", side_effect=ImportError("no module")
), patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
result = json.loads(
text_to_speech_tool("Hello", output_path=str(tmp_path / "out.mp3"))
)
assert result["success"] is False
assert "mistralai" in result["error"]
class TestCheckTtsRequirementsMistral:
def test_mistral_sdk_and_key_returns_true(self, mock_mistral_module, monkeypatch):
from tools.tts_tool import check_tts_requirements
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \
patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \
patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \
patch("tools.tts_tool._check_neutts_available", return_value=False):
assert check_tts_requirements() is True
def test_mistral_key_missing_returns_false(self, mock_mistral_module):
from tools.tts_tool import check_tts_requirements
with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \
patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \
patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \
patch("tools.tts_tool._check_neutts_available", return_value=False), \
patch("tools.tts_tool._check_kittentts_available", return_value=False), \
patch("tools.tts_tool._check_piper_available", return_value=False), \
patch("tools.tts_tool._has_any_command_tts_provider", return_value=False):
assert check_tts_requirements() is False