mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
33ace975d2
Follow-up to the per-alias Entra OBO/app-identity backend auth: the console write path now applies default-deny field classification, the admin shelf gains full backend-auth support, and the session/registry rebind machinery is hardened for config changes landing under live sessions. Console write gate: - Default-deny classification: any non-neutral change to a row that is or becomes dynamic requires admin.mcp plus validation; the provably auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a live-schema classification test forces every future column to be classified. The derivation is a pure function (_derive_auth_gate) with unit-pinned exclusivity invariants. - Two-tier validation mirroring the MCP oauth_obo validator: the row tier (audience allow-list) runs on every gated write; the posture tier (OIDC configured, token store present) runs on pair changes and on enable-arming. - Pure-disable carve-out: disabling a dynamic row is de-escalation and is never blocked — admin.models suffices and validation is skipped, including for rows with corrupt or skewed stored values. - Capabilities are compared canonically (key order, integral floats), the audience compare normalizes both sides, and staging an audience on a static row is refused on both write twins. - Calibrate writes the capabilities column under an enforced confinement invariant with a compare-and-swap persist. Admin shelf: - Backend-auth section with a per-open constraints fetch (GET /model-definitions/auth-constraints: audience allow-list, grant profile, dynamic modes), datalist audience suggestions, server-defined modes preserved on round-trip, and permission-aware visibility built on cache-skew-safe helpers shared through auth.js. - Refused live-registry swaps surface as an amber registry_warning on the write, delete, reload, and calibrate responses; audit rows carry auth_gated / auth_disarmed markers visible in the audit view. Registry and sessions: - The encryption-key requirement for dynamic auth is enforced inside ModelRegistry.reload() itself — nodes refuse with 503 and the console records coord_registry_error — and reload bumps the generation before the map swap so a racing reader can never pair a stale generation with new maps. - resolve()/resolve_binding() return the generation from inside the registry lock; sessions rebind per send on generation change with atomic client/provider/config commits, fallback-first handling of removed or unconstructable aliases, and judge/limiter resets only when the binding actually changed. - Mint refusals record per-user causes surfaced in the per-turn heartbeat logs; misconfiguration warnings are deduplicated with bounded state. Verification: 10417 tests (99 added on this branch), a 71-scenario browser harness over the real admin shelf, and a live rfc8693 token-exchange e2e run (MCP legs verified end to end; the model-leg scope gap is tracked as #955 under a narrow known-gap signature). Closes #950.
538 lines
23 KiB
Python
538 lines
23 KiB
Python
"""Unit tests for the STT/TTS audio helper (model-role resolution + backends).
|
|
|
|
``transcribe`` / ``synthesize`` are exercised through the registry boundary
|
|
with a mocked OpenAI-SDK client (mocking ``client.audio.*``), so the real
|
|
helper code runs end-to-end without a network call.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from turnstone.core import audio
|
|
|
|
|
|
class _Cfg:
|
|
"""Stand-in for ModelConfig — only the fields audio.py reads."""
|
|
|
|
def __init__(
|
|
self,
|
|
model: str,
|
|
capabilities: dict | None = None,
|
|
provider: str = "openai",
|
|
server_compat: dict | None = None,
|
|
) -> None:
|
|
self.model = model
|
|
self.capabilities = capabilities or {}
|
|
self.provider = provider
|
|
self.server_compat = server_compat or {}
|
|
|
|
|
|
class _FakeConfigStore:
|
|
def __init__(self, **values: str) -> None:
|
|
self._values = values
|
|
|
|
def get(self, key: str, default: str = "") -> str:
|
|
return self._values.get(key, default)
|
|
|
|
|
|
class _FakeRegistry:
|
|
"""Minimal registry exposing the surface audio.py uses."""
|
|
|
|
def __init__(self, alias: str, cfg: _Cfg, client: object) -> None:
|
|
self._alias = alias
|
|
self._cfg = cfg
|
|
self._client = client
|
|
|
|
def has_alias(self, alias: str) -> bool:
|
|
return alias == self._alias
|
|
|
|
def get_config(self, alias: str) -> _Cfg:
|
|
if alias != self._alias:
|
|
raise ValueError(alias)
|
|
return self._cfg
|
|
|
|
def resolve(self, alias: str | None = None):
|
|
if alias not in (None, self._alias):
|
|
raise ValueError(alias)
|
|
return self._client, self._cfg.model, self._cfg, 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Capability gating
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestModelSupportsRole:
|
|
def test_explicit_flag_wins(self):
|
|
assert audio.model_supports_role(_Cfg("anything", {"supports_transcription": True}), "stt")
|
|
# Explicit False overrides the would-be inference from the model name.
|
|
assert not audio.model_supports_role(
|
|
_Cfg("gpt-4o-mini-tts", {"supports_speech_synthesis": False}), "tts"
|
|
)
|
|
|
|
def test_infers_known_openai_audio_models(self):
|
|
assert audio.model_supports_role(_Cfg("gpt-4o-mini-transcribe"), "stt")
|
|
assert audio.model_supports_role(_Cfg("whisper-1"), "stt")
|
|
assert audio.model_supports_role(_Cfg("gpt-4o-mini-tts"), "tts")
|
|
assert audio.model_supports_role(_Cfg("tts-1"), "tts")
|
|
|
|
def test_omni_audio_input_eligible_for_stt(self):
|
|
# An omni model (chat audio input) qualifies for STT via the chat path,
|
|
# even with no transcription endpoint and a non-whisper name.
|
|
assert audio.model_supports_role(_Cfg("gemma-omni", {"supports_audio_input": True}), "stt")
|
|
# Audio *input* alone does not make it a TTS (speech-synthesis) model.
|
|
assert not audio.model_supports_role(
|
|
_Cfg("gemma-omni", {"supports_audio_input": True}), "tts"
|
|
)
|
|
|
|
def test_anthropic_provider_excluded_from_audio_roles(self):
|
|
# Anthropic(-compatible) has no audio content block, so it can't serve
|
|
# any audio role — even with a capability flag or a whisper-style name.
|
|
assert not audio.model_supports_role(
|
|
_Cfg("gemma-omni", {"supports_audio_input": True}, provider="anthropic-compatible"),
|
|
"stt",
|
|
)
|
|
assert not audio.model_supports_role(
|
|
_Cfg("whisper-1", provider="anthropic-compatible"), "stt"
|
|
)
|
|
assert not audio.model_supports_role(
|
|
_Cfg("voice", {"supports_speech_synthesis": True}, provider="anthropic"), "tts"
|
|
)
|
|
|
|
def test_chat_model_not_eligible(self):
|
|
assert not audio.model_supports_role(_Cfg("gpt-5"), "stt")
|
|
# Anthropic has no audio API — gated out of every audio role.
|
|
assert not audio.model_supports_role(_Cfg("claude-opus-4-8"), "tts")
|
|
assert not audio.model_supports_role(_Cfg("claude-opus-4-8"), "stt")
|
|
|
|
def test_unknown_role(self):
|
|
assert not audio.model_supports_role(_Cfg("whisper-1"), "vision_eval")
|
|
|
|
def test_hint_seed_lists_are_pinned(self):
|
|
# Mirrored verbatim in admin.js AUDIO_MODEL_HINTS — if these change,
|
|
# update the JS dropdown gate too (this pin makes the change deliberate).
|
|
assert audio._AUDIO_MODEL_HINTS == {
|
|
"stt": ("transcribe", "whisper", "-asr"),
|
|
"tts": ("tts-", "-tts"),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Role resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveRoleAlias:
|
|
def test_resolves_configured_capable_alias(self):
|
|
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-transcribe"), MagicMock())
|
|
cs = _FakeConfigStore(**{"audio.stt_model_alias": "voice"})
|
|
assert audio.resolve_role_alias(config_store=cs, registry=reg, role="stt") == "voice"
|
|
|
|
def test_none_when_unset(self):
|
|
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-transcribe"), MagicMock())
|
|
assert (
|
|
audio.resolve_role_alias(config_store=_FakeConfigStore(), registry=reg, role="stt")
|
|
is None
|
|
)
|
|
|
|
def test_none_when_alias_missing_from_registry(self):
|
|
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-transcribe"), MagicMock())
|
|
cs = _FakeConfigStore(**{"audio.stt_model_alias": "ghost"})
|
|
assert audio.resolve_role_alias(config_store=cs, registry=reg, role="stt") is None
|
|
|
|
def test_none_when_alias_not_capability_eligible(self):
|
|
# Alias exists but its model can't do TTS -> gated out (Anthropic case).
|
|
reg = _FakeRegistry("brain", _Cfg("claude-opus-4-8"), MagicMock())
|
|
cs = _FakeConfigStore(**{"audio.tts_model_alias": "brain"})
|
|
assert audio.resolve_role_alias(config_store=cs, registry=reg, role="tts") is None
|
|
|
|
def test_none_when_no_registry_or_store(self):
|
|
assert audio.resolve_role_alias(config_store=None, registry=None, role="stt") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# transcribe / synthesize — boundary: mocked OpenAI-SDK client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTranscribe:
|
|
def test_calls_audio_transcriptions_and_returns_text(self):
|
|
client = MagicMock()
|
|
client.audio.transcriptions.create.return_value = MagicMock(text=" hello world ")
|
|
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-transcribe"), client)
|
|
res = audio.transcribe(
|
|
registry=reg, alias="voice", data=b"RIFFfake", filename="speech.webm"
|
|
)
|
|
assert res.transcript == "hello world"
|
|
assert res.model_alias == "voice"
|
|
assert res.model == "gpt-4o-mini-transcribe"
|
|
kwargs = client.audio.transcriptions.create.call_args.kwargs
|
|
assert kwargs["model"] == "gpt-4o-mini-transcribe"
|
|
assert kwargs["file"] == ("speech.webm", b"RIFFfake")
|
|
|
|
def test_prompt_forwarded_when_set(self):
|
|
client = MagicMock()
|
|
client.audio.transcriptions.create.return_value = MagicMock(text="ok")
|
|
reg = _FakeRegistry("voice", _Cfg("whisper-1"), client)
|
|
audio.transcribe(
|
|
registry=reg, alias="voice", data=b"x", filename="a.wav", prompt="ACME jargon"
|
|
)
|
|
assert client.audio.transcriptions.create.call_args.kwargs["prompt"] == "ACME jargon"
|
|
|
|
def test_prompt_omitted_when_blank(self):
|
|
client = MagicMock()
|
|
client.audio.transcriptions.create.return_value = MagicMock(text="ok")
|
|
reg = _FakeRegistry("voice", _Cfg("whisper-1"), client)
|
|
audio.transcribe(registry=reg, alias="voice", data=b"x", filename="a.wav")
|
|
assert "prompt" not in client.audio.transcriptions.create.call_args.kwargs
|
|
|
|
def test_backend_failure_raises_backend_error(self):
|
|
client = MagicMock()
|
|
client.audio.transcriptions.create.side_effect = RuntimeError("boom")
|
|
reg = _FakeRegistry("voice", _Cfg("whisper-1"), client)
|
|
with pytest.raises(audio.AudioBackendError):
|
|
audio.transcribe(registry=reg, alias="voice", data=b"x", filename="a.wav")
|
|
|
|
def test_omni_model_transcribes_via_chat(self, monkeypatch):
|
|
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
|
|
client = MagicMock()
|
|
msg = MagicMock(content=" the transcript ")
|
|
client.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=msg)])
|
|
reg = _FakeRegistry("omni", _Cfg("gemma-omni", {"supports_audio_input": True}), client)
|
|
res = audio.transcribe(
|
|
registry=reg, alias="omni", data=b"webmbytes", filename="speech.webm"
|
|
)
|
|
assert res.transcript == "the transcript"
|
|
# The dedicated transcription endpoint is NOT used for an omni model.
|
|
client.audio.transcriptions.create.assert_not_called()
|
|
parts = client.chat.completions.create.call_args.kwargs["messages"][0]["content"]
|
|
# Prompt precedes the audio part — the order Gemma documents for transcription.
|
|
assert [p["type"] for p in parts] == ["text", "input_audio"]
|
|
# The clip is transcoded to wav regardless of the upload container.
|
|
audio_part = next(p for p in parts if p["type"] == "input_audio")
|
|
assert audio_part["input_audio"]["format"] == "wav"
|
|
# A blank prompt falls back to the omni STT default instruction.
|
|
text_part = next(p for p in parts if p["type"] == "text")
|
|
assert "Only output the transcription" in text_part["text"]
|
|
|
|
def test_omni_prompt_override_used(self, monkeypatch):
|
|
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
|
|
client = MagicMock()
|
|
client.chat.completions.create.return_value = MagicMock(
|
|
choices=[MagicMock(message=MagicMock(content="x"))]
|
|
)
|
|
reg = _FakeRegistry("omni", _Cfg("gemma-omni", {"supports_audio_input": True}), client)
|
|
audio.transcribe(
|
|
registry=reg, alias="omni", data=b"x", filename="a.wav", prompt="custom instruction"
|
|
)
|
|
parts = client.chat.completions.create.call_args.kwargs["messages"][0]["content"]
|
|
text_part = next(p for p in parts if p["type"] == "text")
|
|
assert text_part["text"] == "custom instruction"
|
|
|
|
def test_non_audio_provider_raises_clear_error(self):
|
|
# A stale config could still point STT at an anthropic-compatible model
|
|
# (no audio surface): fail with an actionable message, not an opaque
|
|
# ``'Anthropic' object has no attribute 'chat'``.
|
|
client = MagicMock()
|
|
reg = _FakeRegistry(
|
|
"omni",
|
|
_Cfg("gemma", {"supports_audio_input": True}, provider="anthropic-compatible"),
|
|
client,
|
|
)
|
|
with pytest.raises(audio.AudioUnavailableError, match="OpenAI-compatible provider"):
|
|
audio.transcribe(registry=reg, alias="omni", data=b"x", filename="a.webm")
|
|
client.chat.completions.create.assert_not_called()
|
|
|
|
|
|
class TestSynthesize:
|
|
def test_calls_audio_speech_and_returns_bytes(self):
|
|
client = MagicMock()
|
|
speech = MagicMock()
|
|
speech.read.return_value = b"RIFF...wavbytes"
|
|
client.audio.speech.create.return_value = speech
|
|
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-tts"), client)
|
|
res = audio.synthesize(registry=reg, alias="voice", text="hi", voice="nova")
|
|
assert res.audio_bytes == b"RIFF...wavbytes"
|
|
assert res.media_type == "audio/mpeg"
|
|
assert res.model_alias == "voice"
|
|
kwargs = client.audio.speech.create.call_args.kwargs
|
|
assert kwargs["voice"] == "nova"
|
|
assert kwargs["input"] == "hi"
|
|
|
|
def test_default_voice_when_empty(self):
|
|
client = MagicMock()
|
|
client.audio.speech.create.return_value = MagicMock(read=lambda: b"a")
|
|
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-tts"), client)
|
|
audio.synthesize(registry=reg, alias="voice", text="hi", voice="")
|
|
assert client.audio.speech.create.call_args.kwargs["voice"] == "alloy"
|
|
|
|
def test_backend_failure_raises_backend_error(self):
|
|
client = MagicMock()
|
|
client.audio.speech.create.side_effect = RuntimeError("down")
|
|
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-tts"), client)
|
|
with pytest.raises(audio.AudioBackendError):
|
|
audio.synthesize(registry=reg, alias="voice", text="hi", voice="nova")
|
|
|
|
|
|
class TestOpenAIAudioModelsKnown:
|
|
"""The current OpenAI STT/TTS lineup is registered in the static capability
|
|
table, so the admin 'suggested capabilities' recognizes them and they show
|
|
in the known-models list. (Role gating also works via name inference for
|
|
openai-compatible/local backends that aren't in the static table.)"""
|
|
|
|
def test_stt_models_flagged(self):
|
|
from turnstone.core.providers import lookup_model_capabilities
|
|
|
|
for m in (
|
|
"whisper-1",
|
|
"gpt-4o-transcribe",
|
|
"gpt-4o-mini-transcribe",
|
|
"gpt-4o-transcribe-diarize", # prefix variant
|
|
):
|
|
caps = lookup_model_capabilities("openai", m) or {}
|
|
assert caps.get("supports_transcription") is True, m
|
|
assert caps.get("supports_speech_synthesis") is False, m
|
|
|
|
def test_tts_models_flagged(self):
|
|
from turnstone.core.providers import lookup_model_capabilities
|
|
|
|
for m in ("tts-1", "tts-1-hd", "gpt-4o-mini-tts"): # tts-1-hd is a prefix variant
|
|
caps = lookup_model_capabilities("openai", m) or {}
|
|
assert caps.get("supports_speech_synthesis") is True, m
|
|
assert caps.get("supports_transcription") is False, m
|
|
|
|
def test_chat_model_has_no_audio_flags(self):
|
|
from turnstone.core.providers import lookup_model_capabilities
|
|
|
|
caps = lookup_model_capabilities("openai", "gpt-5") or {}
|
|
assert not caps.get("supports_transcription")
|
|
assert not caps.get("supports_speech_synthesis")
|
|
|
|
|
|
class TestTranscribeCached:
|
|
"""The memoized, non-raising transcribe used by the no-native-audio wire
|
|
fallback. Caching an STT result is an audio-domain concern, so it lives here
|
|
next to ``transcribe`` rather than bundled with PDF text extraction."""
|
|
|
|
def _result(self, text: str):
|
|
return audio.TranscriptionResult(transcript=text, model_alias="w", model="m")
|
|
|
|
def test_memoizes_by_alias_and_hash(self, monkeypatch):
|
|
audio._clear_transcript_cache_for_test()
|
|
calls = []
|
|
|
|
def fake(*, registry, alias, data, filename):
|
|
calls.append(1)
|
|
return self._result("hello world")
|
|
|
|
monkeypatch.setattr(audio, "transcribe", fake)
|
|
kw = dict(registry=object(), alias="w", content_hash="h1", data=b"x", filename="a.wav")
|
|
assert audio.transcribe_cached(**kw) == "hello world"
|
|
assert audio.transcribe_cached(**kw) == "hello world"
|
|
assert len(calls) == 1 # second served from cache
|
|
|
|
def test_backend_failure_returns_empty_and_is_not_cached(self, monkeypatch):
|
|
audio._clear_transcript_cache_for_test()
|
|
calls = []
|
|
|
|
def boom(*, registry, alias, data, filename):
|
|
calls.append(1)
|
|
raise audio.AudioBackendError("down")
|
|
|
|
monkeypatch.setattr(audio, "transcribe", boom)
|
|
kw = dict(registry=object(), alias="w", content_hash="h2", data=b"x", filename="a.wav")
|
|
assert audio.transcribe_cached(**kw) == ""
|
|
audio.transcribe_cached(**kw)
|
|
assert len(calls) == 2 # failure not cached -> retried
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Omni chat request shaping — transcode + thinking-off + token cap
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOmniChatExtraBody:
|
|
"""``_omni_chat_extra_body`` re-applies what the raw-client STT path skips."""
|
|
|
|
_THINKING = {"thinking_mode": "manual", "thinking_param": "enable_thinking"}
|
|
|
|
def test_disables_thinking_via_model_param(self):
|
|
cfg = _Cfg("gemma", dict(self._THINKING))
|
|
assert audio._omni_chat_extra_body(cfg) == {
|
|
"chat_template_kwargs": {"enable_thinking": False}
|
|
}
|
|
|
|
def test_thinking_off_wins_over_operator_flag(self):
|
|
cfg = _Cfg(
|
|
"gemma",
|
|
dict(self._THINKING),
|
|
server_compat={"extra_body": {"chat_template_kwargs": {"enable_thinking": True}}},
|
|
)
|
|
# STT never wants reasoning, even if an operator stored thinking on.
|
|
assert audio._omni_chat_extra_body(cfg)["chat_template_kwargs"]["enable_thinking"] is False
|
|
|
|
def test_forwards_operator_server_compat_extra_body(self):
|
|
cfg = _Cfg(
|
|
"model",
|
|
dict(self._THINKING),
|
|
server_compat={"extra_body": {"reasoning_format": "auto"}},
|
|
)
|
|
extra = audio._omni_chat_extra_body(cfg)
|
|
assert extra["reasoning_format"] == "auto"
|
|
assert extra["chat_template_kwargs"] == {"enable_thinking": False}
|
|
|
|
def test_empty_for_non_thinking_model(self):
|
|
cfg = _Cfg("omni", {"supports_audio_input": True})
|
|
assert audio._omni_chat_extra_body(cfg) == {}
|
|
|
|
|
|
class TestOmniChatCall:
|
|
"""The omni chat call carries the thinking-off extra_body and a token cap."""
|
|
|
|
def test_sends_thinking_off_and_token_cap(self, monkeypatch):
|
|
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
|
|
client = MagicMock()
|
|
client.chat.completions.create.return_value = MagicMock(
|
|
choices=[MagicMock(message=MagicMock(content="hi"))]
|
|
)
|
|
cfg = _Cfg(
|
|
"gemma-omni",
|
|
{
|
|
"supports_audio_input": True,
|
|
"thinking_mode": "manual",
|
|
"thinking_param": "enable_thinking",
|
|
},
|
|
)
|
|
audio.transcribe(
|
|
registry=_FakeRegistry("omni", cfg, client),
|
|
alias="omni",
|
|
data=b"webmbytes",
|
|
filename="speech.webm",
|
|
)
|
|
kwargs = client.chat.completions.create.call_args.kwargs
|
|
assert kwargs["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False
|
|
assert kwargs["max_tokens"] == audio._OMNI_STT_MAX_TOKENS
|
|
|
|
|
|
class TestTranscode:
|
|
"""``_to_wav_16k_mono`` normalizes any container to 16 kHz mono WAV via ffmpeg."""
|
|
|
|
def _stereo_wav_44k(self) -> bytes:
|
|
import io
|
|
import wave
|
|
|
|
buf = io.BytesIO()
|
|
with wave.open(buf, "wb") as w:
|
|
w.setnchannels(2)
|
|
w.setsampwidth(2)
|
|
w.setframerate(44100)
|
|
w.writeframes(b"\x00\x01\x00\x01" * 4410) # 0.1 s of stereo
|
|
return buf.getvalue()
|
|
|
|
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
|
|
def test_transcodes_to_16k_mono(self):
|
|
import io
|
|
import wave
|
|
|
|
out = audio._to_wav_16k_mono(self._stereo_wav_44k())
|
|
with wave.open(io.BytesIO(out), "rb") as w:
|
|
assert w.getnchannels() == 1
|
|
assert w.getframerate() == 16000
|
|
|
|
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
|
|
def test_undecodable_bytes_raise_backend_error(self):
|
|
with pytest.raises(audio.AudioBackendError):
|
|
audio._to_wav_16k_mono(b"this is not audio at all")
|
|
|
|
def test_missing_ffmpeg_raises_backend_error(self, monkeypatch):
|
|
def _no_ffmpeg(*a, **k):
|
|
raise FileNotFoundError("ffmpeg")
|
|
|
|
monkeypatch.setattr(audio.subprocess, "run", _no_ffmpeg)
|
|
with pytest.raises(audio.AudioBackendError, match="ffmpeg is not installed"):
|
|
audio._to_wav_16k_mono(b"x")
|
|
|
|
def test_invokes_ffmpeg_with_hardened_argv(self, monkeypatch):
|
|
# Covers the argv shaping even on a CI image without ffmpeg installed.
|
|
captured = {}
|
|
|
|
def _fake_run(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
captured["input"] = kwargs.get("input")
|
|
return MagicMock(returncode=0, stdout=b"RIFF....WAVE", stderr=b"")
|
|
|
|
monkeypatch.setattr(audio.subprocess, "run", _fake_run)
|
|
assert audio._to_wav_16k_mono(b"rawclip") == b"RIFF....WAVE"
|
|
cmd = captured["cmd"]
|
|
assert cmd[0] == "ffmpeg"
|
|
assert captured["input"] == b"rawclip"
|
|
# SSRF/decompression-bomb hardening + the 16 kHz mono normalization.
|
|
assert cmd[cmd.index("-protocol_whitelist") + 1] == "pipe"
|
|
assert "-vn" in cmd
|
|
assert cmd[cmd.index("-ac") + 1] == "1"
|
|
assert cmd[cmd.index("-ar") + 1] == "16000"
|
|
assert cmd[cmd.index("-f") + 1] == "wav"
|
|
|
|
def test_nonzero_returncode_raises_backend_error(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
audio.subprocess,
|
|
"run",
|
|
lambda *a, **k: MagicMock(returncode=1, stdout=b"", stderr=b"boom"),
|
|
)
|
|
with pytest.raises(audio.AudioBackendError, match="Audio transcode failed"):
|
|
audio._to_wav_16k_mono(b"x")
|
|
|
|
|
|
def _stream_chunk(content):
|
|
return MagicMock(choices=[MagicMock(delta=MagicMock(content=content))])
|
|
|
|
|
|
class TestTranscribeStream:
|
|
"""``transcribe_stream`` yields content deltas; resolve/transcode are eager."""
|
|
|
|
def test_streams_chat_deltas_with_thinking_off(self, monkeypatch):
|
|
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
|
|
client = MagicMock()
|
|
client.chat.completions.create.return_value = iter(
|
|
[_stream_chunk("and so"), _stream_chunk(None), _stream_chunk(" my fellow americans")]
|
|
)
|
|
cfg = _Cfg(
|
|
"gemma-omni",
|
|
{
|
|
"supports_audio_input": True,
|
|
"thinking_mode": "manual",
|
|
"thinking_param": "enable_thinking",
|
|
},
|
|
)
|
|
gen = audio.transcribe_stream(
|
|
registry=_FakeRegistry("omni", cfg, client), alias="omni", data=b"webmbytes"
|
|
)
|
|
# Empty/None deltas are skipped; the rest stream through in order.
|
|
assert list(gen) == ["and so", " my fellow americans"]
|
|
kwargs = client.chat.completions.create.call_args.kwargs
|
|
assert kwargs["stream"] is True
|
|
assert kwargs["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False
|
|
|
|
def test_non_audio_provider_raises_before_streaming(self):
|
|
client = MagicMock()
|
|
cfg = _Cfg("gemma", {"supports_audio_input": True}, provider="anthropic-compatible")
|
|
with pytest.raises(audio.AudioUnavailableError, match="OpenAI-compatible provider"):
|
|
audio.transcribe_stream(
|
|
registry=_FakeRegistry("omni", cfg, client), alias="omni", data=b"x"
|
|
)
|
|
client.chat.completions.create.assert_not_called()
|
|
|
|
def test_whisper_alias_emits_single_chunk(self):
|
|
client = MagicMock()
|
|
client.audio.transcriptions.create.return_value = MagicMock(text=" full transcript ")
|
|
cfg = _Cfg("whisper-1") # name inference -> dedicated endpoint, no chat stream
|
|
gen = audio.transcribe_stream(
|
|
registry=_FakeRegistry("w", cfg, client), alias="w", data=b"x"
|
|
)
|
|
assert list(gen) == ["full transcript"]
|
|
client.chat.completions.create.assert_not_called()
|