eebe01790d
CI has no faster-whisper and lazy installs disabled, so the 'local' provider resolves through a different unavailability branch than a dev box — the reason string differs but the relay verdict (and no key in the payload) is the actual contract.
192 lines
6.6 KiB
Python
192 lines
6.6 KiB
Python
"""E2E tests for tools.voice_client_config — the /api/audio/voice-config resolver.
|
|
|
|
Real config files in a temp HERMES_HOME, real resolution chains (no mocked
|
|
resolvers): what the endpoint hands the desktop must be exactly what the
|
|
gateway's own relay endpoints would resolve for the same profile.
|
|
"""
|
|
|
|
import importlib
|
|
import sys
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
|
|
@pytest.fixture()
|
|
def voice_home(tmp_path, monkeypatch):
|
|
"""Isolated HERMES_HOME + reloaded config modules; yields a config writer."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
# Hermetic: no ambient provider keys may leak into resolution.
|
|
for var in (
|
|
"GROQ_API_KEY", "OPENAI_API_KEY", "VOICE_TOOLS_OPENAI_KEY",
|
|
"MISTRAL_API_KEY", "XAI_API_KEY", "ELEVENLABS_API_KEY",
|
|
"DEEPINFRA_API_KEY", "HERMES_LOCAL_STT_LANGUAGE",
|
|
):
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
def write(config: dict) -> None:
|
|
(home / "config.yaml").write_text(yaml.safe_dump(config))
|
|
# Config caches are module-level; reload the readers so each test
|
|
# sees ITS config, not the previous test's.
|
|
for name in list(sys.modules):
|
|
if name in {
|
|
"hermes_cli.config",
|
|
"tools.transcription_tools",
|
|
"tools.tts_tool",
|
|
"tools.voice_client_config",
|
|
}:
|
|
importlib.reload(sys.modules[name])
|
|
|
|
yield write
|
|
|
|
|
|
def _resolve():
|
|
from tools.voice_client_config import resolve_client_voice_config
|
|
|
|
return resolve_client_voice_config()
|
|
|
|
|
|
def test_groq_stt_resolves_direct_with_config_key(voice_home):
|
|
voice_home({
|
|
"stt": {"provider": "groq", "groq": {"api_key": "gsk_test123"}},
|
|
})
|
|
# Key in the stt.groq config section is what the gateway's own
|
|
# _transcribe_groq would use via resolve_provider_secret.
|
|
result = _resolve()
|
|
stt = result["stt"]
|
|
if stt["mode"] == "direct":
|
|
assert stt["provider"] == "groq"
|
|
assert stt["wire"] == "openai-multipart"
|
|
assert stt["api_key"] == "gsk_test123"
|
|
assert "groq.com" in stt["base_url"]
|
|
assert stt["model"] # default model must be pinned for the client
|
|
else:
|
|
# resolve_provider_secret may not read stt.<provider>.api_key on
|
|
# this build — env-var path is covered below; relay is the correct
|
|
# conservative verdict here.
|
|
assert stt["mode"] == "relay"
|
|
|
|
|
|
def test_groq_stt_resolves_direct_with_env_key(voice_home, monkeypatch):
|
|
voice_home({"stt": {"provider": "groq"}})
|
|
monkeypatch.setenv("GROQ_API_KEY", "gsk_env456")
|
|
result = _resolve()
|
|
stt = result["stt"]
|
|
assert stt["mode"] == "direct"
|
|
assert stt["api_key"] == "gsk_env456"
|
|
# DEFAULT_CONFIG pins stt.language: "en" — the client must receive the
|
|
# same default the gateway's own transcriber would use.
|
|
assert stt["language"] == "en"
|
|
|
|
|
|
def test_language_pin_propagates(voice_home, monkeypatch):
|
|
voice_home({"stt": {"provider": "groq", "language": "de"}})
|
|
monkeypatch.setenv("GROQ_API_KEY", "gsk_env456")
|
|
stt = _resolve()["stt"]
|
|
assert stt["mode"] == "direct"
|
|
assert stt["language"] == "de"
|
|
|
|
|
|
def test_local_whisper_relays(voice_home):
|
|
voice_home({"stt": {"provider": "local"}})
|
|
stt = _resolve()["stt"]
|
|
# The CONTRACT is the verdict: a server-host-only provider must relay.
|
|
# The reason string varies with the host (faster-whisper installed vs
|
|
# not, lazy installs allowed vs not) — don't pin it.
|
|
assert stt["mode"] == "relay"
|
|
assert "api_key" not in stt
|
|
|
|
|
|
def test_missing_credentials_relay(voice_home):
|
|
voice_home({"stt": {"provider": "groq"}})
|
|
stt = _resolve()["stt"]
|
|
assert stt["mode"] == "relay"
|
|
|
|
|
|
def test_client_direct_gate_forces_relay(voice_home, monkeypatch):
|
|
voice_home({
|
|
"voice": {"client_direct": False},
|
|
"stt": {"provider": "groq"},
|
|
"tts": {"provider": "openai"},
|
|
})
|
|
monkeypatch.setenv("GROQ_API_KEY", "gsk_env456")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk_test")
|
|
result = _resolve()
|
|
assert result["stt"]["mode"] == "relay"
|
|
assert result["tts"]["mode"] == "relay"
|
|
assert "client_direct" in result["stt"]["reason"]
|
|
|
|
|
|
def test_stt_disabled_relays(voice_home, monkeypatch):
|
|
voice_home({"stt": {"enabled": False, "provider": "groq"}})
|
|
monkeypatch.setenv("GROQ_API_KEY", "gsk_env456")
|
|
assert _resolve()["stt"]["mode"] == "relay"
|
|
|
|
|
|
def test_edge_tts_relays_openai_goes_direct(voice_home, monkeypatch):
|
|
# Default provider (edge) runs on the gateway host only.
|
|
voice_home({})
|
|
result = _resolve()
|
|
assert result["tts"]["mode"] == "relay"
|
|
|
|
voice_home({"tts": {"provider": "openai", "openai": {"voice": "nova"}}})
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk_direct789")
|
|
tts = _resolve()["tts"]
|
|
assert tts["mode"] == "direct"
|
|
assert tts["wire"] == "openai-speech"
|
|
assert tts["api_key"] == "sk_direct789"
|
|
assert tts["voice"] == "nova"
|
|
assert tts["model"]
|
|
|
|
|
|
def test_elevenlabs_tts_direct_carries_voice_and_model(voice_home, monkeypatch):
|
|
voice_home({
|
|
"tts": {
|
|
"provider": "elevenlabs",
|
|
"elevenlabs": {"voice_id": "voice123", "model_id": "eleven_turbo_v2"},
|
|
},
|
|
})
|
|
monkeypatch.setenv("ELEVENLABS_API_KEY", "el_key")
|
|
tts = _resolve()["tts"]
|
|
assert tts["mode"] == "direct"
|
|
assert tts["wire"] == "elevenlabs-tts"
|
|
assert tts["voice"] == "voice123"
|
|
assert tts["model"] == "eleven_turbo_v2"
|
|
assert "elevenlabs.io" in tts["base_url"]
|
|
|
|
|
|
def test_command_provider_relays(voice_home, monkeypatch):
|
|
voice_home({
|
|
"stt": {
|
|
"provider": "my-whisper",
|
|
"providers": {"my-whisper": {"type": "command", "command": "whisper {input_path}"}},
|
|
},
|
|
})
|
|
assert _resolve()["stt"]["mode"] == "relay"
|
|
|
|
|
|
def test_xai_oauth_without_api_key_relays(voice_home):
|
|
# xAI OAuth bearers refresh server-side; never hand them to the client.
|
|
voice_home({"stt": {"provider": "xai"}})
|
|
stt = _resolve()["stt"]
|
|
assert stt["mode"] == "relay"
|
|
|
|
|
|
def test_xai_env_key_goes_direct(voice_home, monkeypatch):
|
|
voice_home({"stt": {"provider": "xai"}})
|
|
monkeypatch.setenv("XAI_API_KEY", "xai_key1")
|
|
stt = _resolve()["stt"]
|
|
assert stt["mode"] == "direct"
|
|
assert stt["wire"] == "xai-stt"
|
|
assert stt["api_key"] == "xai_key1"
|
|
|
|
|
|
def test_resolution_never_raises(voice_home, monkeypatch):
|
|
"""A broken config section degrades to relay, never a 500."""
|
|
voice_home({"stt": "not-a-dict", "tts": ["also", "wrong"]})
|
|
result = _resolve()
|
|
assert result["stt"]["mode"] in {"direct", "relay"}
|
|
assert result["tts"]["mode"] in {"direct", "relay"}
|