test_tts_dotenv_fallback.py
1 """Regression tests for #17140. 2 3 TTS provider tools must resolve API keys from ``~/.hermes/.env`` (via 4 ``hermes_cli.config.get_env_value``) and not only from ``os.environ`` — 5 otherwise users who keep their keys in the dotenv file see "API key not set" 6 errors even though the key is configured. Same class of bug as #15914 (auth) 7 already addressed for ``agent/credential_pool`` and ``hermes_cli/auth``. 8 """ 9 10 from unittest.mock import MagicMock, patch 11 12 import pytest 13 14 15 @pytest.fixture(autouse=True) 16 def isolate_env(monkeypatch): 17 """Strip every TTS-related env var so the test really exercises the 18 dotenv code path. If any of these survive into the test, the assertion 19 that ``get_env_value`` was consulted becomes meaningless because 20 ``os.environ`` already satisfies the lookup. 21 """ 22 for key in ( 23 "ELEVENLABS_API_KEY", 24 "XAI_API_KEY", 25 "XAI_BASE_URL", 26 "MINIMAX_API_KEY", 27 "MISTRAL_API_KEY", 28 "GEMINI_API_KEY", 29 "GEMINI_BASE_URL", 30 "GOOGLE_API_KEY", 31 ): 32 monkeypatch.delenv(key, raising=False) 33 34 35 class TestDotenvFallbackPerProvider: 36 """For each affected provider, when only ``~/.hermes/.env`` carries the 37 key, the provider must find it. These per-provider tests model that 38 dotenv-backed lookup by mocking ``tools.tts_tool.get_env_value`` directly; 39 the separate regression-guard tests cover the lower-level 40 ``hermes_cli.config.load_env`` integration. Before the fix, ``os.getenv`` 41 returned ``None`` and the provider raised 42 ``ValueError("X_API_KEY not set")``. 43 """ 44 45 def test_elevenlabs_reads_dotenv_key(self, tmp_path): 46 from tools import tts_tool 47 48 with patch.object(tts_tool, "get_env_value", return_value="el-dotenv-key"), \ 49 patch.object(tts_tool, "_import_elevenlabs") as mock_import: 50 mock_client = MagicMock() 51 mock_client.text_to_speech.convert.return_value = iter([b"audio"]) 52 mock_import.return_value = MagicMock(return_value=mock_client) 53 54 output = str(tmp_path / "out.mp3") 55 tts_tool._generate_elevenlabs("hi", output, {}) 56 57 mock_import.return_value.assert_called_once_with(api_key="el-dotenv-key") 58 59 def test_xai_reads_dotenv_key(self, tmp_path): 60 from tools import tts_tool 61 62 captured: dict = {} 63 64 def fake_post(url, **kwargs): 65 captured["url"] = url 66 captured["headers"] = kwargs.get("headers", {}) 67 response = MagicMock() 68 response.content = b"audio" 69 response.raise_for_status = MagicMock() 70 return response 71 72 with patch.object(tts_tool, "get_env_value", return_value="xai-dotenv-key"), \ 73 patch("requests.post", side_effect=fake_post): 74 tts_tool._generate_xai_tts("hi", str(tmp_path / "out.mp3"), {}) 75 76 assert captured["headers"]["Authorization"] == "Bearer xai-dotenv-key" 77 78 def test_minimax_reads_dotenv_key(self, tmp_path): 79 from tools import tts_tool 80 81 captured: dict = {} 82 83 def fake_post(url, **kwargs): 84 captured["headers"] = kwargs.get("headers", {}) 85 response = MagicMock() 86 response.json.return_value = { 87 "data": {"audio": b"\x00\x01".hex()}, 88 "base_resp": {"status_code": 0}, 89 } 90 response.raise_for_status = MagicMock() 91 return response 92 93 with patch.object(tts_tool, "get_env_value", return_value="mm-dotenv-key"), \ 94 patch("requests.post", side_effect=fake_post): 95 tts_tool._generate_minimax_tts("hi", str(tmp_path / "out.mp3"), {}) 96 97 assert captured["headers"]["Authorization"] == "Bearer mm-dotenv-key" 98 99 def test_mistral_reads_dotenv_key(self, tmp_path): 100 import base64 101 102 from tools import tts_tool 103 104 seen_keys: list = [] 105 106 def fake_mistral_factory(*, api_key=None): 107 seen_keys.append(api_key) 108 client = MagicMock() 109 client.__enter__ = MagicMock(return_value=client) 110 client.__exit__ = MagicMock(return_value=False) 111 client.audio.speech.complete.return_value = MagicMock( 112 audio_data=base64.b64encode(b"data").decode() 113 ) 114 return client 115 116 with patch.object(tts_tool, "get_env_value", return_value="mistral-dotenv-key"), \ 117 patch.object(tts_tool, "_import_mistral_client", return_value=fake_mistral_factory): 118 tts_tool._generate_mistral_tts("hi", str(tmp_path / "out.mp3"), {}) 119 120 assert seen_keys == ["mistral-dotenv-key"] 121 122 def test_gemini_reads_dotenv_key(self, tmp_path): 123 from tools import tts_tool 124 125 captured: dict = {} 126 127 def fake_post(url, **kwargs): 128 captured["params"] = kwargs.get("params", {}) 129 response = MagicMock() 130 response.status_code = 200 131 response.json.return_value = { 132 "candidates": [ 133 { 134 "content": { 135 "parts": [ 136 { 137 "inlineData": { 138 "data": "AAAA", 139 "mimeType": "audio/L16;codec=pcm;rate=24000", 140 } 141 } 142 ] 143 } 144 } 145 ] 146 } 147 response.raise_for_status = MagicMock() 148 return response 149 150 # GEMINI_API_KEY hits the first branch; GOOGLE_API_KEY would only be 151 # consulted if the first returned None. Use a side-effect-style mock 152 # to verify the lookup order matches the production code. 153 seen_lookups: list = [] 154 155 def fake_get_env_value(key): 156 seen_lookups.append(key) 157 if key == "GEMINI_API_KEY": 158 return "gemini-dotenv-key" 159 return None 160 161 with patch.object(tts_tool, "get_env_value", side_effect=fake_get_env_value), \ 162 patch("requests.post", side_effect=fake_post): 163 tts_tool._generate_gemini_tts("hi", str(tmp_path / "out.wav"), {}) 164 165 assert "GEMINI_API_KEY" in seen_lookups 166 assert captured["params"]["key"] == "gemini-dotenv-key" 167 168 169 class TestRegressionGuard: 170 """Goal-backward proof that the old behaviour ('only check ``os.environ``') 171 breaks reading from a dotenv-only key, and the new behaviour fixes it. 172 Implemented as an end-to-end probe that patches 173 ``hermes_cli.config.load_env`` to simulate ``~/.hermes/.env`` carrying the 174 key while ``os.environ`` does not. 175 """ 176 177 def test_import_after_config_env_patch_uses_restored_dotenv_loader(self, tmp_path, monkeypatch): 178 """Importing TTS while hermes_cli.config.get_env_value is patched must 179 not freeze that temporary helper into this module forever. 180 """ 181 import importlib 182 import hermes_cli.config as config_mod 183 from tools import tts_tool 184 185 monkeypatch.delenv("MINIMAX_API_KEY", raising=False) 186 187 with pytest.MonkeyPatch.context() as mp: 188 mp.setattr(config_mod, "get_env_value", lambda name: "") 189 tts_tool = importlib.reload(tts_tool) 190 191 try: 192 captured: dict = {} 193 194 def fake_post(url, **kwargs): 195 captured["headers"] = kwargs.get("headers", {}) 196 response = MagicMock() 197 response.json.return_value = { 198 "data": {"audio": b"\x00".hex()}, 199 "base_resp": {"status_code": 0}, 200 } 201 response.raise_for_status = MagicMock() 202 return response 203 204 with patch( 205 "hermes_cli.config.load_env", 206 return_value={"MINIMAX_API_KEY": "dotenv-secret"}, 207 ), patch("requests.post", side_effect=fake_post): 208 tts_tool._generate_minimax_tts( 209 "hi", str(tmp_path / "out.mp3"), {} 210 ) 211 212 assert captured["headers"]["Authorization"] == "Bearer dotenv-secret" 213 finally: 214 importlib.reload(tts_tool) 215 216 def test_minimax_missing_when_only_in_dotenv_before_fix(self, tmp_path, monkeypatch): 217 from tools import tts_tool 218 219 monkeypatch.delenv("MINIMAX_API_KEY", raising=False) 220 221 # Simulate ~/.hermes/.env carrying the key (load_env returns the dict 222 # that get_env_value falls back to). The pre-fix ``os.getenv`` call 223 # ignores this entirely and raises ValueError. 224 with patch( 225 "hermes_cli.config.load_env", 226 return_value={"MINIMAX_API_KEY": "dotenv-secret"}, 227 ): 228 # Sanity-check: get_env_value resolves through load_env when 229 # os.environ is empty. 230 from hermes_cli.config import get_env_value as live_get 231 assert live_get("MINIMAX_API_KEY") == "dotenv-secret" 232 233 # And the production code path now consumes the resolved value 234 # instead of raising "MINIMAX_API_KEY not set". 235 captured: dict = {} 236 237 def fake_post(url, **kwargs): 238 captured["headers"] = kwargs.get("headers", {}) 239 response = MagicMock() 240 response.json.return_value = { 241 "data": {"audio": b"\x00".hex()}, 242 "base_resp": {"status_code": 0}, 243 } 244 response.raise_for_status = MagicMock() 245 return response 246 247 with patch("requests.post", side_effect=fake_post): 248 tts_tool._generate_minimax_tts( 249 "hi", str(tmp_path / "out.mp3"), {} 250 ) 251 252 assert captured["headers"]["Authorization"] == "Bearer dotenv-secret" 253 254 def test_check_tts_requirements_sees_dotenv_minimax(self, monkeypatch): 255 """``check_tts_requirements`` is the gate that decides whether 256 ``/voice on`` is even offered. If it only checked ``os.environ`` it 257 would say "no provider available" for users who keep MINIMAX_API_KEY 258 in ``~/.hermes/.env``, even though the dispatcher would later succeed. 259 """ 260 from tools import tts_tool 261 262 monkeypatch.delenv("MINIMAX_API_KEY", raising=False) 263 264 with patch( 265 "hermes_cli.config.load_env", 266 return_value={"MINIMAX_API_KEY": "dotenv-secret"}, 267 ), patch.object(tts_tool, "_import_edge_tts", side_effect=ImportError), \ 268 patch.object(tts_tool, "_import_elevenlabs", side_effect=ImportError), \ 269 patch.object(tts_tool, "_import_openai_client", side_effect=ImportError), \ 270 patch.object(tts_tool, "_check_neutts_available", return_value=False), \ 271 patch.object(tts_tool, "_check_kittentts_available", return_value=False): 272 assert tts_tool.check_tts_requirements() is True