feat(tools): add Voxtral Transcribe STT provider (Mistral AI)
This commit is contained in:
@@ -48,6 +48,7 @@ def clean_env(monkeypatch):
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
monkeypatch.delenv("HERMES_LOCAL_STT_COMMAND", raising=False)
|
||||
monkeypatch.delenv("HERMES_LOCAL_STT_LANGUAGE", raising=False)
|
||||
|
||||
@@ -858,3 +859,183 @@ class TestGetSttModelFromConfig:
|
||||
|
||||
from tools.transcription_tools import get_stt_model_from_config
|
||||
assert get_stt_model_from_config() is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# _transcribe_mistral
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mistral_module():
|
||||
"""Inject a fake mistralai module into sys.modules for testing."""
|
||||
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}):
|
||||
yield mock_client
|
||||
|
||||
|
||||
class TestTranscribeMistral:
|
||||
def test_no_key(self, monkeypatch):
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
from tools.transcription_tools import _transcribe_mistral
|
||||
result = _transcribe_mistral("/tmp/test.ogg", "voxtral-mini-latest")
|
||||
assert result["success"] is False
|
||||
assert "MISTRAL_API_KEY" in result["error"]
|
||||
|
||||
def test_successful_transcription(self, monkeypatch, sample_ogg, mock_mistral_module):
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.text = "hello from mistral"
|
||||
mock_mistral_module.audio.transcriptions.complete.return_value = mock_result
|
||||
|
||||
from tools.transcription_tools import _transcribe_mistral
|
||||
result = _transcribe_mistral(sample_ogg, "voxtral-mini-latest")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["transcript"] == "hello from mistral"
|
||||
assert result["provider"] == "mistral"
|
||||
mock_mistral_module.audio.transcriptions.complete.assert_called_once()
|
||||
mock_mistral_module.__exit__.assert_called_once()
|
||||
|
||||
def test_api_error_returns_failure(self, monkeypatch, sample_ogg, mock_mistral_module):
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
mock_mistral_module.audio.transcriptions.complete.side_effect = RuntimeError("secret-key-leaked")
|
||||
|
||||
from tools.transcription_tools import _transcribe_mistral
|
||||
result = _transcribe_mistral(sample_ogg, "voxtral-mini-latest")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "RuntimeError" in result["error"]
|
||||
assert "secret-key-leaked" not in result["error"]
|
||||
|
||||
def test_permission_error(self, monkeypatch, sample_ogg, mock_mistral_module):
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
mock_mistral_module.audio.transcriptions.complete.side_effect = PermissionError("denied")
|
||||
|
||||
from tools.transcription_tools import _transcribe_mistral
|
||||
result = _transcribe_mistral(sample_ogg, "voxtral-mini-latest")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Permission denied" in result["error"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# _get_provider — Mistral
|
||||
# ============================================================================
|
||||
|
||||
class TestGetProviderMistral:
|
||||
"""Mistral-specific provider selection tests."""
|
||||
|
||||
def test_mistral_when_key_and_sdk_available(self, monkeypatch):
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "mistral"}) == "mistral"
|
||||
|
||||
def test_mistral_explicit_no_key_returns_none(self, monkeypatch):
|
||||
"""Explicit mistral with no key returns none — no cross-provider fallback."""
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
with patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "mistral"}) == "none"
|
||||
|
||||
def test_mistral_explicit_no_sdk_returns_none(self, monkeypatch):
|
||||
"""Explicit mistral with key but no SDK returns none."""
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_MISTRAL", False):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "mistral"}) == "none"
|
||||
|
||||
def test_auto_detect_mistral_after_openai(self, monkeypatch):
|
||||
"""Auto-detect: mistral is tried after openai when both are unavailable."""
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", False), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "mistral"
|
||||
|
||||
def test_auto_detect_openai_preferred_over_mistral(self, monkeypatch):
|
||||
"""Auto-detect: openai is preferred over mistral (both paid, openai more common)."""
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", True), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "openai"
|
||||
|
||||
def test_auto_detect_groq_preferred_over_mistral(self, monkeypatch):
|
||||
"""Auto-detect: groq (free) is preferred over mistral (paid)."""
|
||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", True), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "groq"
|
||||
|
||||
def test_auto_detect_skips_mistral_without_sdk(self, monkeypatch):
|
||||
"""Auto-detect: mistral skipped when key is set but SDK is not installed."""
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", False), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", False):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "none"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# transcribe_audio — Mistral dispatch
|
||||
# ============================================================================
|
||||
|
||||
class TestTranscribeAudioMistralDispatch:
|
||||
def test_dispatches_to_mistral(self, sample_ogg):
|
||||
with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "mistral"}), \
|
||||
patch("tools.transcription_tools._get_provider", return_value="mistral"), \
|
||||
patch("tools.transcription_tools._transcribe_mistral",
|
||||
return_value={"success": True, "transcript": "hi", "provider": "mistral"}) as mock_mistral:
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
result = transcribe_audio(sample_ogg)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "mistral"
|
||||
mock_mistral.assert_called_once()
|
||||
|
||||
def test_config_mistral_model_used(self, sample_ogg):
|
||||
config = {"provider": "mistral", "mistral": {"model": "voxtral-mini-2602"}}
|
||||
with patch("tools.transcription_tools._load_stt_config", return_value=config), \
|
||||
patch("tools.transcription_tools._get_provider", return_value="mistral"), \
|
||||
patch("tools.transcription_tools._transcribe_mistral",
|
||||
return_value={"success": True, "transcript": "hi"}) as mock_mistral:
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
transcribe_audio(sample_ogg, model=None)
|
||||
|
||||
assert mock_mistral.call_args[0][1] == "voxtral-mini-2602"
|
||||
|
||||
def test_model_override_passed_to_mistral(self, sample_ogg):
|
||||
with patch("tools.transcription_tools._load_stt_config", return_value={}), \
|
||||
patch("tools.transcription_tools._get_provider", return_value="mistral"), \
|
||||
patch("tools.transcription_tools._transcribe_mistral",
|
||||
return_value={"success": True, "transcript": "hi"}) as mock_mistral:
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
transcribe_audio(sample_ogg, model="voxtral-mini-2602")
|
||||
|
||||
assert mock_mistral.call_args[0][1] == "voxtral-mini-2602"
|
||||
|
||||
Reference in New Issue
Block a user