/ tests / tools / test_tts_dotenv_fallback.py
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