From a5074c0ca8f050e19aad2b780fcdf9595891530f Mon Sep 17 00:00:00 2001 From: luyifan Date: Tue, 30 Jun 2026 04:39:32 +0800 Subject: [PATCH] fix(stt): report unregistered configured providers --- .../test_transcription_plugin_dispatch.py | 28 +++++++++++--- tools/transcription_tools.py | 37 ++++++++++++++++--- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/tests/tools/test_transcription_plugin_dispatch.py b/tests/tools/test_transcription_plugin_dispatch.py index d7ea8dac4ee3..7bfd311de1a8 100644 --- a/tests/tools/test_transcription_plugin_dispatch.py +++ b/tests/tools/test_transcription_plugin_dispatch.py @@ -8,7 +8,7 @@ to #30398 — STT pluggability): built-in name (which the registry blocks), the dispatcher re-checks defensively. 2. Unknown name with no plugin → returns None (caller surfaces the - legacy "No STT provider available" error). + "provider_not_registered" error). 3. Unknown name with plugin registered → dispatches, returns result. 4. Plugin exceptions are caught and converted to the standard error envelope. @@ -235,10 +235,8 @@ class TestTranscribeAudioE2E: assert result["transcript"] == "fake transcript" assert result["provider"] == "openrouter" - def test_unknown_name_without_plugin_falls_to_legacy_error(self, sample_audio_file): - """When no plugin is registered for the unknown name, the - dispatcher returns None and transcribe_audio falls through to - the legacy 'No STT provider available' error message.""" + def test_unknown_name_without_plugin_returns_provider_specific_error(self, sample_audio_file): + """Explicit unknown providers should get a named registration error.""" from unittest.mock import patch with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \ @@ -247,7 +245,25 @@ class TestTranscribeAudioE2E: result = transcription_tools.transcribe_audio(sample_audio_file) assert result["success"] is False - assert "No STT provider" in result["error"] + assert result["provider"] == "openrouter" + assert result["error_type"] == "provider_not_registered" + assert "stt.provider='openrouter'" in result["error"] + assert "hermes plugins list" in result["error"] + assert "No STT provider available" not in result["error"] + + def test_auto_detect_failure_keeps_legacy_no_provider_message(self): + """No explicit stt.provider remains the generic setup guidance path.""" + from unittest.mock import patch + + with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ + patch("tools.transcription_tools._load_stt_config", return_value={}), \ + patch("tools.transcription_tools.is_stt_enabled", return_value=True), \ + patch("tools.transcription_tools._get_provider", return_value="none"): + result = transcription_tools.transcribe_audio("/tmp/audio.mp3") + + assert result["success"] is False + assert result.get("error_type") is None + assert "No STT provider available" in result["error"] def test_builtin_name_does_not_consult_plugin_registry(self, sample_audio_file): """Even if a plugin's name collides with a built-in (which the diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 2e155dd83cae..4ee26abb9c89 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -949,6 +949,22 @@ def _get_provider(stt_config: dict) -> str: return "none" +def _unregistered_stt_provider_error(provider: str) -> Dict[str, Any]: + key = str(provider or "").strip() + return { + "success": False, + "transcript": "", + "provider": key, + "error_type": "provider_not_registered", + "error": ( + f"stt.provider='{key}' is set but no built-in, command, or plugin " + "provider registered that name. Run `hermes plugins list` to see " + "installed STT plugins, or configure a command provider under " + f"`stt.providers.{key}.command`." + ), + } + + # --------------------------------------------------------------------------- # Plugin provider dispatch (issue follow-up to #30398 — STT pluggability) # --------------------------------------------------------------------------- @@ -965,8 +981,8 @@ def _dispatch_to_plugin_provider( """Route the call to a plugin-registered transcription provider, or return None. - Returns the transcribe-response dict on dispatch, or ``None`` to - fall through to the legacy "No STT provider available" error path. + Returns the transcribe-response dict on dispatch, or ``None`` when no + plugin claimed the provider name. Resolution invariants enforced here: @@ -984,8 +1000,8 @@ def _dispatch_to_plugin_provider( 3. Plugin dispatch fires only when ``provider`` matches a registered :class:`TranscriptionProvider` whose ``name`` equals the configured value. Unknown names with no plugin registered - return None (caller surfaces the legacy "No STT provider" - message). + return None (caller surfaces the configured-provider error when + the name came from ``stt.provider``). 4. Availability gating: when the matched plugin reports ``is_available() == False`` (missing API key, missing optional SDK, etc.) this returns an error envelope identifying the @@ -1983,8 +1999,8 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A # nor ``"none"`` AND there is no same-name command provider. The # dispatcher enforces built-ins-always-win + command-wins-over-plugin # defensively. Returns None when no plugin is registered for the - # configured name, falling through to the legacy "No STT provider" - # error message below. + # configured name; explicit configured names get a provider-specific + # error before the generic auto-detect fallback below. # # Plugin-scoped config namespace mirrors the built-in pattern # (``stt.openai.model``, ``stt.mistral.model``): plugins read their @@ -2004,6 +2020,15 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A if plugin_result is not None: return plugin_result + provider_key = str(provider or "").strip().lower() + if ( + "provider" in stt_config + and provider_key + and provider_key not in BUILTIN_STT_PROVIDERS + and provider_key != "none" + ): + return _unregistered_stt_provider_error(provider_key) + # No provider available return { "success": False,