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
217 lines
7.8 KiB
Python
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."
|
|
)
|