mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-25 17:42:50 +08:00
add fal ElevenLabs speech and secure audio routing
This commit is contained in:
@@ -694,6 +694,7 @@ class TestCapabilityMetadata:
|
||||
"dashscope",
|
||||
"doubao",
|
||||
"elevenlabs",
|
||||
"fal.ai",
|
||||
"google_tts",
|
||||
"kling_official",
|
||||
"openai",
|
||||
|
||||
103
tests/tools/test_fal_elevenlabs_music.py
Normal file
103
tests/tools/test_fal_elevenlabs_music.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tools.audio.fal_elevenlabs_music import FalElevenLabsMusic
|
||||
from tools.base_tool import ToolStatus
|
||||
from tools.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
def _response(*, json_data=None, content=b""):
|
||||
response = MagicMock()
|
||||
response.json.return_value = json_data
|
||||
response.content = content
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
def test_contract_and_rounded_cost(monkeypatch):
|
||||
tool = FalElevenLabsMusic()
|
||||
|
||||
monkeypatch.setenv("FAL_KEY", "test-key")
|
||||
assert tool.get_status() == ToolStatus.AVAILABLE
|
||||
assert tool.estimate_cost({"duration_seconds": 20}) == 0.80
|
||||
assert tool.estimate_cost({"duration_seconds": 61}) == 1.60
|
||||
assert tool.get_info()["capability"] == "music_generation"
|
||||
assert tool.get_info()["provider"] == "fal.ai"
|
||||
|
||||
|
||||
def test_registry_discovers_provider(monkeypatch):
|
||||
monkeypatch.setenv("FAL_KEY", "test-key")
|
||||
registry = ToolRegistry()
|
||||
registry.discover()
|
||||
|
||||
tool = registry.get("fal_elevenlabs_music")
|
||||
assert tool is not None
|
||||
assert tool.get_status() == ToolStatus.AVAILABLE
|
||||
|
||||
|
||||
def test_execute_submits_once_and_downloads_audio(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FAL_KEY", "test-key")
|
||||
output_path = tmp_path / "music.mp3"
|
||||
tool = FalElevenLabsMusic()
|
||||
tool._POLL_INTERVAL_SECONDS = 0
|
||||
|
||||
post_response = _response(
|
||||
json_data={
|
||||
"status_url": "https://queue.example/status",
|
||||
"response_url": "https://queue.example/result",
|
||||
}
|
||||
)
|
||||
status_response = _response(json_data={"status": "COMPLETED"})
|
||||
result_response = _response(
|
||||
json_data={"audio": {"url": "https://media.example/music.mp3"}}
|
||||
)
|
||||
audio_response = _response(content=b"fake-mp3")
|
||||
|
||||
with (
|
||||
patch("requests.post", return_value=post_response) as mock_post,
|
||||
patch(
|
||||
"requests.get",
|
||||
side_effect=[status_response, result_response, audio_response],
|
||||
) as mock_get,
|
||||
):
|
||||
result = tool.execute(
|
||||
{
|
||||
"prompt": "gentle felt piano",
|
||||
"duration_seconds": 20,
|
||||
"force_instrumental": True,
|
||||
"output_path": str(output_path),
|
||||
}
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.cost_usd == 0.80
|
||||
assert result.model == "fal-ai/elevenlabs/music"
|
||||
assert output_path.read_bytes() == b"fake-mp3"
|
||||
assert mock_post.call_count == 1
|
||||
assert mock_post.call_args.kwargs["json"]["music_length_ms"] == 20000
|
||||
assert mock_post.call_args.kwargs["json"]["force_instrumental"] is True
|
||||
assert mock_get.call_count == 3
|
||||
|
||||
|
||||
def test_execute_rejects_missing_duration_without_request(monkeypatch):
|
||||
monkeypatch.setenv("FAL_KEY", "test-key")
|
||||
with patch("requests.post") as mock_post:
|
||||
result = FalElevenLabsMusic().execute({"prompt": "gentle piano"})
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "duration_seconds is required"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
def test_error_redacts_fal_key(monkeypatch):
|
||||
secret = "test-production-shaped-fal-key"
|
||||
monkeypatch.setenv("FAL_KEY", secret)
|
||||
with patch("requests.post", side_effect=RuntimeError(f"request used {secret}")):
|
||||
result = FalElevenLabsMusic().execute(
|
||||
{"prompt": "gentle piano", "duration_seconds": 20}
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert secret not in result.error
|
||||
assert "[REDACTED]" in result.error
|
||||
106
tests/tools/test_fal_elevenlabs_tts.py
Normal file
106
tests/tools/test_fal_elevenlabs_tts.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tools.audio.fal_elevenlabs_tts import FalElevenLabsTTS
|
||||
from tools.base_tool import ToolStatus
|
||||
from tools.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
def _response(*, json_data=None, content=b""):
|
||||
response = MagicMock()
|
||||
response.json.return_value = json_data
|
||||
response.content = content
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
def test_contract_models_and_cost(monkeypatch):
|
||||
monkeypatch.setenv("FAL_KEY", "test-key")
|
||||
tool = FalElevenLabsTTS()
|
||||
|
||||
assert tool.get_status() == ToolStatus.AVAILABLE
|
||||
assert tool.get_info()["capability"] == "tts"
|
||||
assert tool.get_info()["provider"] == "fal.ai"
|
||||
assert tool.estimate_cost({"text": "a" * 1000, "model_id": "eleven-v3"}) == 0.1
|
||||
assert tool.estimate_cost({"text": "a" * 1000, "model_id": "multilingual-v2"}) == 0.1
|
||||
assert tool.estimate_cost({"text": "a" * 1000, "model_id": "turbo-v2.5"}) == 0.05
|
||||
|
||||
|
||||
def test_registry_discovers_fal_tts(monkeypatch):
|
||||
monkeypatch.setenv("FAL_KEY", "test-key")
|
||||
registry = ToolRegistry()
|
||||
registry.discover()
|
||||
|
||||
tool = registry.get("fal_elevenlabs_tts")
|
||||
assert tool is not None
|
||||
assert tool.get_status() == ToolStatus.AVAILABLE
|
||||
|
||||
|
||||
def test_execute_submits_once_and_downloads_audio(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FAL_KEY", "test-key")
|
||||
output_path = tmp_path / "speech.mp3"
|
||||
tool = FalElevenLabsTTS()
|
||||
tool._POLL_INTERVAL_SECONDS = 0
|
||||
|
||||
post_response = _response(
|
||||
json_data={
|
||||
"status_url": "https://queue.example/status",
|
||||
"response_url": "https://queue.example/result",
|
||||
}
|
||||
)
|
||||
status_response = _response(json_data={"status": "COMPLETED"})
|
||||
result_response = _response(
|
||||
json_data={"audio": {"url": "https://media.example/speech.mp3"}}
|
||||
)
|
||||
audio_response = _response(content=b"fake-mp3")
|
||||
|
||||
with (
|
||||
patch("requests.post", return_value=post_response) as mock_post,
|
||||
patch(
|
||||
"requests.get",
|
||||
side_effect=[status_response, result_response, audio_response],
|
||||
) as mock_get,
|
||||
):
|
||||
result = tool.execute(
|
||||
{
|
||||
"text": "A calm, measured test.",
|
||||
"voice_id": "Rachel",
|
||||
"model_id": "eleven-v3",
|
||||
"stability": 0.65,
|
||||
"language_code": "en",
|
||||
"output_path": str(output_path),
|
||||
}
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.model == "fal-ai/elevenlabs/tts/eleven-v3"
|
||||
assert output_path.read_bytes() == b"fake-mp3"
|
||||
assert mock_post.call_count == 1
|
||||
assert mock_post.call_args.args[0].endswith("/fal-ai/elevenlabs/tts/eleven-v3")
|
||||
assert mock_post.call_args.kwargs["json"]["voice"] == "Rachel"
|
||||
assert mock_post.call_args.kwargs["json"]["stability"] == 0.65
|
||||
assert mock_get.call_count == 3
|
||||
|
||||
|
||||
def test_invalid_model_does_not_submit(monkeypatch):
|
||||
monkeypatch.setenv("FAL_KEY", "test-key")
|
||||
with patch("requests.post") as mock_post:
|
||||
result = FalElevenLabsTTS().execute(
|
||||
{"text": "hello", "model_id": "not-a-model"}
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "model_id must be one of" in result.error
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
def test_error_redacts_fal_key(monkeypatch):
|
||||
secret = "test-production-shaped-fal-key"
|
||||
monkeypatch.setenv("FAL_KEY", secret)
|
||||
with patch("requests.post", side_effect=RuntimeError(f"request used {secret}")):
|
||||
result = FalElevenLabsTTS().execute({"text": "hello"})
|
||||
|
||||
assert result.success is False
|
||||
assert secret not in result.error
|
||||
assert "[REDACTED]" in result.error
|
||||
@@ -34,3 +34,26 @@ def test_tts_key_uses_header_and_is_redacted_from_errors(monkeypatch, tmp_path):
|
||||
assert result.success is False
|
||||
assert secret not in result.error
|
||||
assert "[REDACTED]" in result.error
|
||||
|
||||
|
||||
def test_production_adapter_can_force_google_tts_to_ipv4(monkeypatch, tmp_path):
|
||||
import socket
|
||||
|
||||
import requests
|
||||
import urllib3.util.connection
|
||||
|
||||
monkeypatch.setenv("GOOGLE_TTS_API_KEY", "test-key")
|
||||
monkeypatch.setenv("GOOGLE_TTS_FORCE_IPV4", "1")
|
||||
original = urllib3.util.connection.allowed_gai_family
|
||||
|
||||
def inspect_request(url, **kwargs):
|
||||
assert urllib3.util.connection.allowed_gai_family() == socket.AF_INET
|
||||
raise requests.HTTPError("safe expected failure")
|
||||
|
||||
monkeypatch.setattr(requests, "post", inspect_request)
|
||||
result = GoogleTTS().execute(
|
||||
{"text": "safe test sentence", "output_path": str(tmp_path / "speech.mp3")}
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert urllib3.util.connection.allowed_gai_family is original
|
||||
|
||||
Reference in New Issue
Block a user