Files
hermes-agent/tests/tools/test_transcription_plugin_dispatch.py
Teknium d62533c27f fix(stt): long recordings fit each provider's upload cap instead of failing
Cloud STT refused anything over a flat 25 MB with "File too large", and only CLI
voice mode had a WAV-only chunker. Gateway voice notes, audio attachments and
Desktop/TUI relays just failed. xAI (500 MB), Mistral (500 MB) and ElevenLabs
(5 GB) were capped at 25 MB too.

transcribe_audio now owns this for every surface. tools/transcription_chunking.py
holds the vendor-documented per-provider byte caps plus OpenAI per-model duration
caps that were measured live. gpt-4o-transcribe truncates at its 2,000-token output
ceiling, so 1322 s of speech came back 203/260 complete, and it rejects more than
1400 s. whisper-1 at 2671 s outran the 60 s request timeout. An oversized input is
re-encoded to the compact STT m4a first, which needs no seams. When it is still too
large, it is cut at silences under the cap and the transcripts are joined in order.
Hints (stt.prompt, pre_transcription hooks) resolve once per recording, not once per
segment. The voice-mode-only WAV chunker is gone.
2026-10-06 12:08:49 -07:00

308 lines
13 KiB
Python

"""Tests for STT plugin dispatch in tools/transcription_tools.py.
Covers the resolution invariants of the new plugin dispatcher (follow-up
to #30398 — STT pluggability):
1. Built-in provider names short-circuit — plugins NEVER win over a
built-in. Even if a plugin somehow ended up in the registry with a
built-in name (which the registry blocks), the dispatcher re-checks
defensively.
2. Unknown name with no plugin → returns None (caller surfaces the
"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.
5. Plugin returning non-dict → caught with error envelope.
6. Plugin result has ``provider`` field stamped if missing.
"""
from __future__ import annotations
import pytest
from agent import transcription_registry
from agent.transcription_provider import TranscriptionProvider
from tools import transcription_tools
class _FakeProvider(TranscriptionProvider):
def __init__(
self,
name: str,
result: dict | None = None,
raise_exc: BaseException | None = None,
available: bool = True,
available_raises: BaseException | None = None,
):
self._name = name
self._result = result
self._raise_exc = raise_exc
self._available = available
self._available_raises = available_raises
self.last_call: dict | None = None
@property
def name(self) -> str:
return self._name
def is_available(self) -> bool:
if self._available_raises is not None:
raise self._available_raises
return self._available
def transcribe(self, file_path: str, **kw):
self.last_call = {"file_path": file_path, "kwargs": dict(kw)}
if self._raise_exc is not None:
raise self._raise_exc
if self._result is not None:
return self._result
return {"success": True, "transcript": "fake transcript", "provider": self._name}
@pytest.fixture(autouse=True)
def _reset_registry():
transcription_registry._reset_for_tests()
yield
transcription_registry._reset_for_tests()
@pytest.fixture
def sample_audio_file(tmp_path):
"""Return a real, small input so E2E tests exercise validation too."""
audio_path = tmp_path / "audio.mp3"
audio_path.write_bytes(b"fake audio data")
return str(audio_path)
# ---------------------------------------------------------------------------
# Built-in always wins
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Unknown names
# ---------------------------------------------------------------------------
class TestPluginDispatch:
def test_registered_plugin_called(self):
provider = _FakeProvider(name="openrouter")
transcription_registry.register_provider(provider)
result = transcription_tools._dispatch_to_plugin_provider(
"/tmp/audio.mp3", "openrouter",
)
assert result is not None
assert result["success"] is True
assert result["transcript"] == "fake transcript"
assert result["provider"] == "openrouter"
assert provider.last_call is not None
assert provider.last_call["file_path"] == "/tmp/audio.mp3"
def test_unregistered_name_returns_none(self):
"""Unknown name + no plugin → return None so the caller surfaces
the legacy 'No STT provider available' error."""
result = transcription_tools._dispatch_to_plugin_provider(
"/tmp/audio.mp3", "unknown-stt",
)
assert result is None
def test_provider_field_stamped_if_missing(self):
"""If a plugin forgets to set ``provider`` in its result, the
dispatcher stamps it from the registered name."""
provider = _FakeProvider(
name="openrouter",
result={"success": True, "transcript": "hi"}, # no provider key
)
transcription_registry.register_provider(provider)
result = transcription_tools._dispatch_to_plugin_provider(
"/tmp/audio.mp3", "openrouter",
)
assert result is not None
assert result["provider"] == "openrouter"
# ---------------------------------------------------------------------------
# End-to-end via transcribe_audio
# ---------------------------------------------------------------------------
class TestTranscribeAudioE2E:
"""transcribe_audio() routes plugin dispatch correctly when the
configured name is unknown to the built-in branches.
Provider resolution is mocked to isolate routing, while a real small
input keeps the validation chain active.
"""
def test_unknown_name_with_plugin_dispatches(self, sample_audio_file):
from unittest.mock import patch
provider = _FakeProvider(name="openrouter")
transcription_registry.register_provider(provider)
with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \
patch("tools.transcription_tools.is_stt_enabled", return_value=True), \
patch("tools.transcription_tools._get_provider", return_value="openrouter"):
result = transcription_tools.transcribe_audio(sample_audio_file)
assert result["success"] is True
assert result["transcript"] == "fake transcript"
assert result["provider"] == "openrouter"
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
registry blocks, but defense in depth matters), transcribe_audio
with provider='groq' goes through the legacy elif chain, never
the plugin dispatcher."""
from unittest.mock import patch
# Register a plugin that WOULD respond to 'openrouter' — but
# we're asking for 'groq', so it shouldn't be called.
provider = _FakeProvider(name="openrouter")
transcription_registry.register_provider(provider)
with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "groq"}), \
patch("tools.transcription_tools._get_provider", return_value="groq"), \
patch("tools.transcription_tools._transcribe_groq",
return_value={"success": True, "transcript": "from groq", "provider": "groq"}) as mock_groq:
result = transcription_tools.transcribe_audio(sample_audio_file)
assert result["provider"] == "groq"
assert result["transcript"] == "from groq"
mock_groq.assert_called_once()
# Plugin was never called
assert provider.last_call is None
def test_unsplittable_oversized_plugin_file_never_reaches_the_plugin(self, tmp_path):
"""Over the upload cap with no way to fit it (ffmpeg missing) -> refused, plugin untouched."""
from unittest.mock import patch
provider = _FakeProvider(name="openrouter")
transcription_registry.register_provider(provider)
audio_path = tmp_path / "oversized.mp3"
with audio_path.open("wb") as audio_file:
from tools.transcription_common import MAX_FILE_SIZE
audio_file.seek(MAX_FILE_SIZE)
audio_file.write(b"\0")
with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \
patch("tools.transcription_tools.is_stt_enabled", return_value=True), \
patch("tools.transcription_tools._get_provider", return_value="openrouter"), \
patch("tools.transcription_chunking._find_ffmpeg_binary", return_value=None):
result = transcription_tools.transcribe_audio(str(audio_path))
assert result["success"] is False
assert "File too large" in result["error"]
assert provider.last_call is None
# ---------------------------------------------------------------------------
# Availability gating (codex review feedback on PR #30493)
# ---------------------------------------------------------------------------
class TestAvailabilityGate:
"""When the configured plugin reports ``is_available() == False``,
the dispatcher MUST short-circuit with a clear unavailability
envelope instead of routing the call into a plugin that'll crash.
The user explicitly set ``stt.provider: <plugin>`` so falling
through to the generic "No STT provider available" message would
be misleading — surface the plugin's own unavailability instead.
"""
def test_unavailable_plugin_returns_envelope_not_none(self):
provider = _FakeProvider(name="openrouter", available=False)
transcription_registry.register_provider(provider)
result = transcription_tools._dispatch_to_plugin_provider(
"/tmp/audio.mp3", "openrouter",
)
assert result is not None, (
"Unavailable plugin must return an envelope, not None — "
"otherwise we fall through to the generic auto-detect error "
"even though the user explicitly opted into this plugin."
)
assert result["success"] is False
assert result["provider"] == "openrouter"
assert "not available" in result["error"]
# Plugin's transcribe MUST NOT have been called
assert provider.last_call is None
def test_unavailable_plugin_at_transcribe_audio_level(self, sample_audio_file):
"""End-to-end: ``stt.provider: openrouter`` + plugin reports
unavailable → ``transcribe_audio`` returns the unavailability
envelope, NOT the generic "No STT provider available" message.
"""
from unittest.mock import patch
provider = _FakeProvider(name="openrouter", available=False)
transcription_registry.register_provider(provider)
with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \
patch("tools.transcription_tools.is_stt_enabled", return_value=True), \
patch("tools.transcription_tools._get_provider", return_value="openrouter"):
result = transcription_tools.transcribe_audio(sample_audio_file)
assert result["success"] is False
# Must surface the plugin's unavailability — NOT the generic
# "No STT provider available" auto-detect-failure message.
assert "not available" in result["error"]
assert "No STT provider available" not in result["error"]
assert result["provider"] == "openrouter"
# ---------------------------------------------------------------------------
# Language forwarding from config (codex review feedback on PR #30493)
# ---------------------------------------------------------------------------
class TestLanguageForwardingFromConfig:
"""``transcribe_audio`` must forward ``stt.<provider>.language``
from config to the plugin (mirrors how built-ins read
``stt.local.language``).
"""
def test_language_read_from_provider_namespaced_config(self, sample_audio_file):
"""``stt.openrouter.language: ja`` reaches the plugin's
transcribe() call as language='ja'."""
from unittest.mock import patch
provider = _FakeProvider(name="openrouter")
transcription_registry.register_provider(provider)
stt_config = {
"provider": "openrouter",
"openrouter": {"language": "ja"},
}
with patch("tools.transcription_tools._load_stt_config", return_value=stt_config), \
patch("tools.transcription_tools.is_stt_enabled", return_value=True), \
patch("tools.transcription_tools._get_provider", return_value="openrouter"):
transcription_tools.transcribe_audio(sample_audio_file)
assert provider.last_call is not None
assert provider.last_call["kwargs"]["language"] == "ja"
def test_non_dict_provider_namespace_does_not_crash(self, sample_audio_file):
"""If someone accidentally writes ``stt.openrouter: "foo"`` (a
string instead of a dict), we should not crash — treat as
empty config."""
from unittest.mock import patch
provider = _FakeProvider(name="openrouter")
transcription_registry.register_provider(provider)
stt_config = {"provider": "openrouter", "openrouter": "garbage"}
with patch("tools.transcription_tools._load_stt_config", return_value=stt_config), \
patch("tools.transcription_tools.is_stt_enabled", return_value=True), \
patch("tools.transcription_tools._get_provider", return_value="openrouter"):
result = transcription_tools.transcribe_audio(sample_audio_file)
# Should still dispatch successfully (config is just ignored)
assert result["success"] is True
assert provider.last_call["kwargs"]["language"] is None
assert provider.last_call["kwargs"]["model"] is None