From 10dc1571bcb11d3e63351c8f0d813e68c60e3d53 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:04:06 +0530 Subject: [PATCH] fix(deepinfra): align refresh and TTS availability Forward explicit catalog refreshes and make the TTS availability gate follow the configured provider instead of unrelated credentials. --- hermes_cli/models.py | 2 +- tests/hermes_cli/test_api_key_providers.py | 20 +++- tests/tools/test_tts_command_providers.py | 5 +- tests/tools/test_tts_deepinfra.py | 23 +++++ tests/tools/test_tts_dotenv_fallback.py | 2 + tests/tools/test_tts_gemini.py | 5 +- tests/tools/test_tts_mistral.py | 5 +- tests/tools/test_tts_piper.py | 1 + tools/tts_tool.py | 113 ++++++++++++--------- 9 files changed, 122 insertions(+), 54 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 425e191ee34c..a6dd34d0361c 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2392,7 +2392,7 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) # DeepInfra's generic /models endpoint mixes chat, image, video, # speech, and embedding models. The tagged catalog helper is the only # safe source for the chat picker, including its empty/failure result. - return _fetch_deepinfra_models() or [] + return _fetch_deepinfra_models(force_refresh=force_refresh) or [] if normalized == "ollama-cloud": live = fetch_ollama_cloud_models(force_refresh=force_refresh) if live: diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index a151eb0c2665..66b76ed38a90 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -1454,7 +1454,9 @@ class TestFetchDeepInfraModels: profile = get_provider_profile("deepinfra") assert profile is not None - monkeypatch.setattr(models, "_fetch_deepinfra_models", lambda: None) + monkeypatch.setattr( + models, "_fetch_deepinfra_models", lambda **kwargs: None + ) monkeypatch.setattr( profile, "fetch_models", @@ -1464,6 +1466,22 @@ class TestFetchDeepInfraModels: assert models.provider_model_ids("deepinfra") == [] + def test_force_refresh_reaches_deepinfra_catalog(self, monkeypatch): + import hermes_cli.models as models + + seen = [] + + def _fetch(*, force_refresh=False, **kwargs): + seen.append(force_refresh) + return ["vendor/chat"] + + monkeypatch.setattr(models, "_fetch_deepinfra_models", _fetch) + + assert models.provider_model_ids("deepinfra", force_refresh=True) == [ + "vendor/chat" + ] + assert seen == [True] + def test_excludes_non_chat_models(self, monkeypatch): monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") diff --git a/tests/tools/test_tts_command_providers.py b/tests/tools/test_tts_command_providers.py index e3242274a005..616a88d7b88d 100644 --- a/tests/tools/test_tts_command_providers.py +++ b/tests/tools/test_tts_command_providers.py @@ -493,6 +493,9 @@ class TestTextToSpeechToolWithCommandProvider: class TestCheckTtsRequirements: def test_configured_command_provider_satisfies_requirement(self): - cfg = {"providers": {"x": {"type": "command", "command": "echo x"}}} + cfg = { + "provider": "x", + "providers": {"x": {"type": "command", "command": "echo x"}}, + } with patch("tools.tts_tool._load_tts_config", return_value=cfg): assert check_tts_requirements() is True diff --git a/tests/tools/test_tts_deepinfra.py b/tests/tools/test_tts_deepinfra.py index dbceec2179c1..7f0b7ad43d5d 100644 --- a/tests/tools/test_tts_deepinfra.py +++ b/tests/tools/test_tts_deepinfra.py @@ -57,3 +57,26 @@ def test_delegates_to_openai_handler_with_deepinfra_creds(monkeypatch, tmp_path) assert "deepinfra" in captured["base_url"] assert captured["api_key"] == "test-key" + + +def test_requirements_follow_explicit_deepinfra_provider(monkeypatch): + from tools import tts_tool + + monkeypatch.setattr( + tts_tool, + "_load_tts_config", + lambda: {"provider": "deepinfra", "deepinfra": {}}, + ) + monkeypatch.setattr(tts_tool, "_import_openai_client", lambda: object) + + assert tts_tool.check_tts_requirements() is True + + +def test_unselected_cloud_credentials_do_not_expose_edge_tool(monkeypatch): + from tools import tts_tool + + monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: {}) + monkeypatch.setattr(tts_tool, "_import_edge_tts", MagicMock(side_effect=ImportError)) + monkeypatch.setenv("OPENAI_API_KEY", "unselected-key") + + assert tts_tool.check_tts_requirements() is False diff --git a/tests/tools/test_tts_dotenv_fallback.py b/tests/tools/test_tts_dotenv_fallback.py index 0a4ea5a8ac2e..979890486f49 100644 --- a/tests/tools/test_tts_dotenv_fallback.py +++ b/tests/tools/test_tts_dotenv_fallback.py @@ -269,6 +269,8 @@ class TestRegressionGuard: with patch( "hermes_cli.config.load_env", return_value={"MINIMAX_API_KEY": "dotenv-secret"}, + ), patch.object( + tts_tool, "_load_tts_config", return_value={"provider": "minimax"} ), patch.object(tts_tool, "_import_edge_tts", side_effect=ImportError), \ patch.object(tts_tool, "_import_elevenlabs", side_effect=ImportError), \ patch.object(tts_tool, "_import_openai_client", side_effect=ImportError), \ diff --git a/tests/tools/test_tts_gemini.py b/tests/tools/test_tts_gemini.py index 85254649d53d..15e26bdbb122 100644 --- a/tests/tools/test_tts_gemini.py +++ b/tests/tools/test_tts_gemini.py @@ -447,5 +447,8 @@ class TestGeminiInCheckRequirements: raise ImportError("simulated") return real_import(name, *args, **kwargs) - with patch("builtins.__import__", side_effect=fake_import): + with patch( + "tools.tts_tool._load_tts_config", + return_value={"provider": "gemini"}, + ), patch("builtins.__import__", side_effect=fake_import): assert check_tts_requirements() is True diff --git a/tests/tools/test_tts_mistral.py b/tests/tools/test_tts_mistral.py index 6e98946b6c0c..03735ff85f31 100644 --- a/tests/tools/test_tts_mistral.py +++ b/tests/tools/test_tts_mistral.py @@ -204,7 +204,10 @@ class TestCheckTtsRequirementsMistral: 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), \ + with patch( + "tools.tts_tool._load_tts_config", + return_value={"provider": "mistral"}, + ), 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): diff --git a/tests/tools/test_tts_piper.py b/tests/tools/test_tts_piper.py index 78567adf9bbc..9de07d70c40d 100644 --- a/tests/tools/test_tts_piper.py +++ b/tests/tools/test_tts_piper.py @@ -376,6 +376,7 @@ class TestTextToSpeechToolWithPiper: class TestCheckTtsRequirementsPiper: def test_piper_install_satisfies_requirements(self, monkeypatch): # Drop every other provider so we can isolate the piper signal. + monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: {"provider": "piper"}) monkeypatch.setattr(tts_tool, "_import_edge_tts", lambda: (_ for _ in ()).throw(ImportError())) monkeypatch.setattr(tts_tool, "_import_elevenlabs", lambda: (_ for _ in ()).throw(ImportError())) monkeypatch.setattr(tts_tool, "_import_openai_client", lambda: (_ for _ in ()).throw(ImportError())) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 77d7041d9181..7d571e66b1ad 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -2595,60 +2595,75 @@ def text_to_speech_tool( # Requirements check # =========================================================================== def check_tts_requirements() -> bool: + """Return whether the explicitly resolved TTS provider can run. + + Availability must mirror :func:`text_to_speech_tool` dispatch. Unrelated + cloud credentials do not make the default Edge backend usable, and an + explicitly selected backend is checked on its own requirements. """ - Check if at least one TTS provider is available. - - Edge TTS needs no API key and is the default, so if the package - is installed, TTS is available. A user-declared command provider - also satisfies the requirement. - - Returns: - bool: True if at least one provider can work. - """ - # Any configured command provider counts as available. - if _has_any_command_tts_provider(): + tts_config = _load_tts_config() + provider = _get_provider(tts_config) + command_config = _resolve_command_provider_config(provider, tts_config) + if command_config is not None: return True - try: - _import_edge_tts() - return True - except ImportError: - pass - try: - _import_elevenlabs() - if get_env_value("ELEVENLABS_API_KEY"): - return True - except ImportError: - pass - try: - _import_openai_client() - if _has_openai_audio_backend(): - return True - except ImportError: - pass - if get_env_value("MINIMAX_API_KEY"): - return True - try: - from tools.xai_http import resolve_xai_http_credentials - if resolve_xai_http_credentials().get("api_key"): + if provider == "edge": + try: + _import_edge_tts() return True + except ImportError: + return _check_neutts_available() + if provider == "elevenlabs": + try: + _import_elevenlabs() + except ImportError: + return False + return bool(get_env_value("ELEVENLABS_API_KEY")) + if provider == "openai": + try: + _import_openai_client() + except ImportError: + return False + return _has_openai_audio_backend() + if provider == "deepinfra": + try: + _import_openai_client() + except ImportError: + return False + return bool(get_env_value("DEEPINFRA_API_KEY")) + if provider == "minimax": + return bool(get_env_value("MINIMAX_API_KEY")) + if provider == "xai": + try: + from tools.xai_http import resolve_xai_http_credentials + + return bool(resolve_xai_http_credentials().get("api_key")) + except Exception: + return False + if provider == "gemini": + return bool(get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY")) + if provider == "mistral": + try: + _import_mistral_client() + except ImportError: + return False + return bool(get_env_value("MISTRAL_API_KEY")) + if provider == "neutts": + return _check_neutts_available() + if provider == "kittentts": + return _check_kittentts_available() + if provider == "piper": + return _check_piper_available() + + try: + from agent.tts_registry import get_provider + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + plugin = get_provider(provider) + return bool(plugin and plugin.is_available()) except Exception: - pass - if get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY"): - return True - try: - _import_mistral_client() - if get_env_value("MISTRAL_API_KEY"): - return True - except ImportError: - pass - if _check_neutts_available(): - return True - if _check_kittentts_available(): - return True - if _check_piper_available(): - return True - return False + return False def _resolve_openai_audio_client_config() -> tuple[str, str, bool]: