Skip to content
6 changes: 6 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -1127,8 +1127,14 @@ stt:
local:
model: "base" # tiny | base | small | medium | large-v3 | turbo
# language: "" # auto-detect; set to "en", "es", "fr", etc. to force
# initial_prompt: "" # Optional faster-whisper prompt, e.g. bias Chinese output to simplified Chinese
# language: "" # GLOBAL language hint for every STT provider (per-provider language wins)
# groq:
# model: "whisper-large-v3-turbo"
# language: "" # blank = stt.language > HERMES_LOCAL_STT_LANGUAGE > auto-detect
openai:
model: "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
language: "" # auto-detect; set to "en", "es", "fr", etc. to force
# mistral:
# model: "voxtral-mini-latest" # voxtral-mini-latest | voxtral-mini-2602
# deepinfra:
Expand Down
1 change: 1 addition & 0 deletions contributors/emails/materemias@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
materemias
1 change: 1 addition & 0 deletions contributors/emails/zombopanda@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
zombopanda
13 changes: 13 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2322,15 +2322,28 @@ def _ensure_hermes_home_managed(home: Path):
# Set false to keep STT for the agent while suppressing that user-facing echo.
"echo_transcripts": True,
"provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) | "deepinfra"
# Global language hint applied to EVERY provider unless a per-provider
# language overrides it. Empty = auto-detect. ISO-639-1 ("en", "es", ...).
"language": "",
"local": {
"model": "base", # tiny, base, small, medium, large-v3
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
"initial_prompt": "",
},
"groq": {
"model": "whisper-large-v3-turbo", # whisper-large-v3, whisper-large-v3-turbo, distil-whisper-large-v3-en
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
},
"openai": {
"model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
},
"mistral": {
"model": "voxtral-mini-latest", # voxtral-mini-latest, voxtral-mini-2602
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
},
"xai": {
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
},
"elevenlabs": {
"model_id": "scribe_v2", # scribe_v2, scribe_v1
Expand Down
123 changes: 123 additions & 0 deletions tests/tools/test_stt_language_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Tests for the unified STT language resolution (_resolve_stt_language).

Class-level contract: EVERY provider resolves its language hint through one
helper with the order:

stt.<provider>.language > stt.language (global) > HERMES_LOCAL_STT_LANGUAGE > None

Regression coverage for the "STT transcribes the wrong language" issue class
(#55551, #50181 and siblings):
- xAI no longer silently forces "en" when nothing is configured
- the global ``stt.language`` key reaches every provider
- per-provider language still wins over the global key
"""

from unittest.mock import patch

import pytest

from tools.transcription_tools import _resolve_stt_language


@pytest.fixture(autouse=True)
def _clear_lang_env(monkeypatch):
monkeypatch.delenv("HERMES_LOCAL_STT_LANGUAGE", raising=False)


class TestResolveSttLanguage:
def test_provider_language_wins(self):
cfg = {"language": "en", "groq": {"language": "he"}}
assert _resolve_stt_language("groq", cfg) == "he"

def test_global_language_fallback(self):
cfg = {"language": "hu", "groq": {}}
assert _resolve_stt_language("groq", cfg) == "hu"

def test_global_language_reaches_provider_without_section(self):
cfg = {"language": "uk"}
for provider in ("local", "groq", "openai", "mistral", "xai", "elevenlabs", "deepinfra"):
assert _resolve_stt_language(provider, cfg) == "uk", provider

def test_env_var_fallback(self, monkeypatch):
monkeypatch.setenv("HERMES_LOCAL_STT_LANGUAGE", "de")
assert _resolve_stt_language("openai", {}) == "de"

def test_auto_detect_when_nothing_set(self):
assert _resolve_stt_language("xai", {}) is None

def test_blank_strings_are_skipped(self):
cfg = {"language": " ", "groq": {"language": ""}}
assert _resolve_stt_language("groq", cfg) is None

def test_extra_keys_alias(self):
cfg = {"elevenlabs": {"language_code": "spa"}}
assert _resolve_stt_language("elevenlabs", cfg, extra_keys=("language_code",)) == "spa"

def test_null_provider_section(self):
# YAML `stt.groq: null` must not crash
cfg = {"groq": None, "language": "fr"}
assert _resolve_stt_language("groq", cfg) == "fr"

def test_value_is_stripped(self):
cfg = {"language": " ja "}
assert _resolve_stt_language("local", cfg) == "ja"


class TestXaiNoForcedEnglish:
"""xAI previously defaulted language to "en" — auto-detect must be the default."""

def test_no_language_sent_by_default(self, tmp_path, monkeypatch):
import tools.transcription_tools as tt
audio = tmp_path / "a.ogg"
audio.write_bytes(b"x")
captured = {}

class _Resp:
status_code = 200

@staticmethod
def json():
return {"text": "hola", "language": "es", "duration": 1.0}

import requests as _requests

def fake_post(url, **kwargs):
captured["data"] = kwargs.get("data")
return _Resp()

monkeypatch.setattr(_requests, "post", fake_post)
with patch.object(tt, "_load_stt_config", return_value={}), \
patch("tools.xai_http.resolve_xai_http_credentials",
return_value={"api_key": "xai-test", "base_url": "https://api.x.ai/v1"}):
result = tt._transcribe_xai(str(audio), "grok-stt")

assert result["success"] is True
assert "language" not in (captured["data"] or {}), \
"xAI must auto-detect when no language is configured (was forced to 'en')"

def test_global_language_reaches_xai(self, tmp_path, monkeypatch):
import tools.transcription_tools as tt
audio = tmp_path / "a.ogg"
audio.write_bytes(b"x")
captured = {}

class _Resp:
status_code = 200

@staticmethod
def json():
return {"text": "ok", "language": "he", "duration": 1.0}

import requests as _requests

monkeypatch.setattr(
_requests, "post",
lambda url, **kw: captured.update(data=kw.get("data")) or _Resp(),
)
with patch.object(tt, "_load_stt_config", return_value={"language": "he"}), \
patch("tools.xai_http.resolve_xai_http_credentials",
return_value={"api_key": "xai-test", "base_url": "https://api.x.ai/v1"}):
result = tt._transcribe_xai(str(audio), "grok-stt")

assert result["success"] is True
assert captured["data"]["language"] == "he"
125 changes: 125 additions & 0 deletions tests/tools/test_transcription.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,27 @@ def test_too_large(self, tmp_path):
assert "too large" in result["error"]


# ---------------------------------------------------------------------------
# Config resolution
# ---------------------------------------------------------------------------


class TestLoadSttConfig:

def test_merges_default_local_initial_prompt(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(
"stt:\n local:\n model: small\n",
encoding="utf-8",
)

from tools.transcription_tools import _load_stt_config
local_config = _load_stt_config()["local"]

assert local_config["model"] == "small"
assert local_config["initial_prompt"] == ""


# ---------------------------------------------------------------------------
# Local transcription
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -152,6 +173,72 @@ def test_successful_transcription(self, tmp_path):
assert result["success"] is True
assert result["transcript"] == "Hello world"

def test_passes_initial_prompt_when_configured(self, tmp_path):
audio_file = tmp_path / "test.ogg"
audio_file.write_bytes(b"fake audio")

mock_info = MagicMock(language="zh", duration=2.5)
mock_model = MagicMock()
mock_model.transcribe.return_value = ([], mock_info)

fake_fw = _fake_faster_whisper_module(mock_model)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
patch("tools.transcription_tools._load_stt_config", return_value={
"local": {"initial_prompt": "以下是普通话的句子,使用简体中文。"},
}), \
patch.dict("sys.modules", {"faster_whisper": fake_fw}), \
patch("tools.transcription_tools._local_model", None):
from tools.transcription_tools import _transcribe_local
result = _transcribe_local(str(audio_file), "base")

assert result["success"] is True
assert mock_model.transcribe.call_args.kwargs["initial_prompt"] == (
"以下是普通话的句子,使用简体中文。"
)

def test_omits_blank_initial_prompt(self, tmp_path):
audio_file = tmp_path / "test.ogg"
audio_file.write_bytes(b"fake audio")

mock_info = MagicMock(language="en", duration=2.5)
mock_model = MagicMock()
mock_model.transcribe.return_value = ([], mock_info)

fake_fw = _fake_faster_whisper_module(mock_model)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
patch("tools.transcription_tools._load_stt_config", return_value={
"local": {"initial_prompt": " "},
}), \
patch.dict("sys.modules", {"faster_whisper": fake_fw}), \
patch("tools.transcription_tools._local_model", None):
from tools.transcription_tools import _transcribe_local
result = _transcribe_local(str(audio_file), "base")

assert result["success"] is True
assert "initial_prompt" not in mock_model.transcribe.call_args.kwargs

def test_accepts_null_local_config(self, monkeypatch, tmp_path):
monkeypatch.delenv("HERMES_LOCAL_STT_LANGUAGE", raising=False)
audio_file = tmp_path / "test.ogg"
audio_file.write_bytes(b"fake audio")

mock_info = MagicMock(language="en", duration=2.5)
mock_model = MagicMock()
mock_model.transcribe.return_value = ([], mock_info)

fake_fw = _fake_faster_whisper_module(mock_model)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
patch("tools.transcription_tools._load_stt_config", return_value={
"local": None,
}), \
patch.dict("sys.modules", {"faster_whisper": fake_fw}), \
patch("tools.transcription_tools._local_model", None):
from tools.transcription_tools import _transcribe_local
result = _transcribe_local(str(audio_file), "base")

assert result["success"] is True
assert mock_model.transcribe.call_args.kwargs == {"beam_size": 5}

def test_not_installed(self):
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False):
from tools.transcription_tools import _transcribe_local
Expand Down Expand Up @@ -190,6 +277,44 @@ def test_successful_transcription(self, monkeypatch, tmp_path):
assert result["success"] is True
assert result["transcript"] == "Hello from OpenAI"

def test_configured_language_is_forwarded(self, monkeypatch, tmp_path):
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
audio_file = tmp_path / "test.ogg"
audio_file.write_bytes(b"fake audio")

mock_client = MagicMock()
mock_client.audio.transcriptions.create.return_value = "Привіт"

with patch("tools.transcription_tools._HAS_OPENAI", True), \
patch("tools.transcription_tools._load_stt_config", return_value={
"openai": {"language": "uk"},
}), \
patch("openai.OpenAI", return_value=mock_client):
from tools.transcription_tools import _transcribe_openai
result = _transcribe_openai(str(audio_file), "whisper-1")

assert result["success"] is True
assert mock_client.audio.transcriptions.create.call_args.kwargs["language"] == "uk"

def test_unset_language_omits_argument(self, monkeypatch, tmp_path):
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
audio_file = tmp_path / "test.ogg"
audio_file.write_bytes(b"fake audio")

mock_client = MagicMock()
mock_client.audio.transcriptions.create.return_value = "Hello"

with patch("tools.transcription_tools._HAS_OPENAI", True), \
patch("tools.transcription_tools._load_stt_config", return_value={
"openai": {"language": ""},
}), \
patch("openai.OpenAI", return_value=mock_client):
from tools.transcription_tools import _transcribe_openai
result = _transcribe_openai(str(audio_file), "whisper-1")

assert result["success"] is True
assert "language" not in mock_client.audio.transcriptions.create.call_args.kwargs


# ---------------------------------------------------------------------------
# Main transcribe_audio() dispatch
Expand Down
Loading
Loading