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.
308 lines
13 KiB
Python
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
|