Files
hermes-agent/tests/tools/test_tts_provider_authority.py
liuhao1024 5b671e29a4 fix(tts): make tts.provider the authoritative backend selector
The per-call provider argument on text_to_speech (added via #73513,
closing #47459) let the model reroute speech to any configured backend,
contradicting the tool's own schema header ("Voice and provider are
user-configured ... not model-selected") and leaving compact channels
unable to keep a house voice without prompt-layer appendices.

- drop provider from the model-facing TTS_SCHEMA
- stop forwarding args["provider"] in the registry handler
- keep the function argument for internal/test callers, but a value
  disagreeing with the configured tts.provider is now ignored with a
  warning (both text_to_speech_tool and _text_to_speech_single)

Fixes #90109
2026-10-05 16:42:22 -05:00

217 lines
7.8 KiB
Python

"""Tests for config-authoritative TTS provider selection (#90109).
``tts.provider`` in config.yaml is the only backend selector: the
model-facing ``text_to_speech`` schema must not advertise a per-call
``provider`` override, and a per-call value that disagrees with the
configured provider is ignored (with a warning) instead of rerouting
speech to another vendor.
"""
import json
import logging
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
def _fake_key(tag: str) -> str:
return "-".join((("tts", tag, "test", "key")))
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in ("OPENAI_API_KEY", "HERMES_SESSION_PLATFORM"):
monkeypatch.delenv(key, raising=False)
# The ignored-override warning is once-per-process per distinct value;
# reset it so one test's emission never masks the next test's assertion.
from tools import tts_tool as _tts_mod
_tts_mod._once_warnings.clear()
class _OpenaiBackendStub:
"""Patch set that routes the configured ``openai`` provider through a
mocked OpenAI client writing a tiny MP3 payload (same shape as
tests/tools/test_tts_instructions.py)."""
def __init__(self):
self.mock_client = MagicMock()
def fake_stream(path):
Path(path).write_bytes(b"ID3\x03\x00\x00\x00\x00\x00\x00")
response = MagicMock()
response.stream_to_file.side_effect = fake_stream
self.mock_client.audio.speech.create.return_value = response
mock_cls = MagicMock(return_value=self.mock_client)
self.xai_patch = patch("tools.tts_tool._generate_xai_tts")
self.xai_generator = self.xai_patch.start()
self._patches = [
patch("tools.tts_tool._import_openai_client", return_value=mock_cls),
patch(
"tools.tts_tool_openai._resolve_openai_audio_client_config",
return_value=(_fake_key("openai"), None, False),
),
patch(
"tools.tts_tool._load_tts_config",
return_value={"provider": "openai"},
),
]
for p in self._patches:
p.start()
@property
def create(self):
return self.mock_client.audio.speech.create
def stop(self):
for p in self._patches:
p.stop()
self.xai_patch.stop()
@pytest.fixture
def openai_backend():
stub = _OpenaiBackendStub()
yield stub
stub.stop()
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
class TestSchema:
def test_schema_does_not_advertise_provider(self):
from tools.tts_tool import TTS_SCHEMA
props = TTS_SCHEMA["parameters"]["properties"]
assert "provider" not in props, (
"The model-facing text_to_speech schema must not advertise a "
"provider override: tts.provider in config.yaml is the only "
"backend selector (#90109)."
)
# ---------------------------------------------------------------------------
# Tool-level enforcement (text_to_speech_tool)
# ---------------------------------------------------------------------------
class TestToolLevelProviderAuthority:
def test_disagreeing_override_is_ignored(
self, tmp_path, monkeypatch, openai_backend
):
"""A per-call provider that disagrees with config is not honored:
the configured openai backend runs and xAI never fires."""
from tools.tts_tool import text_to_speech_tool
result = json.loads(
text_to_speech_tool(
"Hello world",
output_path=str(tmp_path / "out.mp3"),
provider="xai",
)
)
assert result.get("success") is True
assert result.get("provider") == "openai"
openai_backend.create.assert_called_once()
openai_backend.xai_generator.assert_not_called()
def test_disagreeing_override_logs_warning(
self, tmp_path, monkeypatch, openai_backend, caplog
):
from tools.tts_tool import text_to_speech_tool
with caplog.at_level(logging.WARNING, logger="tools.tts_tool"):
text_to_speech_tool(
"Hello world",
output_path=str(tmp_path / "out.mp3"),
provider="xai",
)
assert any(
"Ignoring per-call TTS provider override" in rec.message
for rec in caplog.records
), "An ignored provider override must be visible in the logs."
def test_matching_override_is_accepted(
self, tmp_path, monkeypatch, openai_backend, caplog
):
"""A per-call provider that agrees with config stays a no-op."""
from tools.tts_tool import text_to_speech_tool
with caplog.at_level(logging.WARNING, logger="tools.tts_tool"):
result = json.loads(
text_to_speech_tool(
"Hello world",
output_path=str(tmp_path / "out.mp3"),
provider="openai",
)
)
assert result.get("success") is True
assert result.get("provider") == "openai"
assert not any(
"Ignoring per-call TTS provider override" in rec.message
for rec in caplog.records
)
# ---------------------------------------------------------------------------
# Registered handler (the model-args path)
# ---------------------------------------------------------------------------
class TestRegisteredHandler:
def test_handler_does_not_forward_provider_arg(
self, tmp_path, monkeypatch, openai_backend
):
"""Even if a model (or a leaked platform hint) sends provider= in
its tool args, the registered handler must not forward it."""
from tools.registry import registry
entry = registry.get_entry("text_to_speech")
assert entry is not None
result = json.loads(
entry.handler(
{
"text": "Hello world",
"output_path": str(tmp_path / "out.mp3"),
"provider": "xai",
}
)
)
assert result.get("success") is True
assert result.get("provider") == "openai"
openai_backend.xai_generator.assert_not_called()
# ---------------------------------------------------------------------------
# Resolver (_apply_call_overrides) — the single provider-resolution point
# ---------------------------------------------------------------------------
class TestResolverAuthority:
def test_resolver_returns_configured_provider_despite_override(self, openai_backend):
from tools.tts_tool import _apply_call_overrides
tts_config, provider = _apply_call_overrides({"provider": "openai"}, None, "xai")
assert provider == "openai"
def test_resolver_returns_configured_provider_when_unset(self, openai_backend):
from tools.tts_tool import _apply_call_overrides
tts_config, provider = _apply_call_overrides({"provider": "openai"}, None, None)
assert provider == "openai"
def test_warning_is_emitted_once_per_distinct_value(self, openai_backend, caplog):
"""A stale session retrying the same override does not spam the log."""
from tools.tts_tool import _apply_call_overrides
with caplog.at_level(logging.WARNING, logger="tools.tts_tool"):
_apply_call_overrides({"provider": "openai"}, None, "xai")
_apply_call_overrides({"provider": "openai"}, None, "xai")
_apply_call_overrides({"provider": "openai"}, None, "mistral")
warnings = [rec for rec in caplog.records
if "Ignoring per-call TTS provider override" in rec.message]
assert len(warnings) == 2, (
"The same requested value must warn once; a distinct value warns again."
)